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,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
# Immutable value object representing the result of a publish operation.
|
|
5
|
+
# Provides structured feedback instead of boolean returns, improving debuggability.
|
|
6
|
+
#
|
|
7
|
+
# @example Successful publish
|
|
8
|
+
# result = publisher.publish_to_topic('notifications', { text: 'Hello' })
|
|
9
|
+
# if result.success?
|
|
10
|
+
# puts "Published with event_id: #{result.event_id}"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# @example Failed publish
|
|
14
|
+
# result = publisher.publish_to_topic('invalid', { })
|
|
15
|
+
# unless result.success?
|
|
16
|
+
# puts "Failed: #{result.reason} - #{result.details}"
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @attr_reader success [Boolean] Whether publish succeeded
|
|
20
|
+
# @attr_reader event_id [String, nil] Event ID if successful
|
|
21
|
+
# @attr_reader subject [String, nil] NATS subject published to
|
|
22
|
+
# @attr_reader reason [Symbol, nil] Failure reason if unsuccessful
|
|
23
|
+
# @attr_reader details [String, nil] Detailed error message if unsuccessful
|
|
24
|
+
# @attr_reader error [Exception, nil] Original exception if available
|
|
25
|
+
class PublishResult
|
|
26
|
+
attr_reader :success, :event_id, :subject, :reason, :details, :error
|
|
27
|
+
|
|
28
|
+
# Create a successful publish result
|
|
29
|
+
#
|
|
30
|
+
# @param event_id [String] Event identifier
|
|
31
|
+
# @param subject [String] NATS subject
|
|
32
|
+
# @return [PublishResult] Success result
|
|
33
|
+
def self.success(event_id:, subject:)
|
|
34
|
+
new(success: true, event_id: event_id, subject: subject)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Create a failed publish result
|
|
38
|
+
#
|
|
39
|
+
# @param reason [Symbol] Failure reason (:validation_error, :io_error, :timeout, etc.)
|
|
40
|
+
# @param details [String] Detailed error message
|
|
41
|
+
# @param subject [String, nil] NATS subject if known
|
|
42
|
+
# @param error [Exception, nil] Original exception
|
|
43
|
+
# @return [PublishResult] Failure result
|
|
44
|
+
def self.failure(reason:, details:, subject: nil, error: nil)
|
|
45
|
+
new(success: false, reason: reason, details: details, subject: subject, error: error)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Initialize a PublishResult
|
|
49
|
+
#
|
|
50
|
+
# @param success [Boolean] Success flag
|
|
51
|
+
# @param event_id [String, nil] Event ID
|
|
52
|
+
# @param subject [String, nil] NATS subject
|
|
53
|
+
# @param reason [Symbol, nil] Failure reason
|
|
54
|
+
# @param details [String, nil] Error details
|
|
55
|
+
# @param error [Exception, nil] Original exception
|
|
56
|
+
def initialize(success:, event_id: nil, subject: nil, reason: nil, details: nil, error: nil)
|
|
57
|
+
@success = success
|
|
58
|
+
@event_id = event_id
|
|
59
|
+
@subject = subject
|
|
60
|
+
@reason = reason
|
|
61
|
+
@details = details
|
|
62
|
+
@error = error
|
|
63
|
+
freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if publish was successful
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] True if successful
|
|
69
|
+
def success?
|
|
70
|
+
@success
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if publish failed
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean] True if failed
|
|
76
|
+
def failure?
|
|
77
|
+
!@success
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if failure was due to validation error
|
|
81
|
+
#
|
|
82
|
+
# @return [Boolean] True if validation error
|
|
83
|
+
def validation_error?
|
|
84
|
+
@reason == :validation_error
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if failure was due to IO/network error
|
|
88
|
+
#
|
|
89
|
+
# @return [Boolean] True if IO error
|
|
90
|
+
def io_error?
|
|
91
|
+
@reason == :io_error
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check if failure was due to timeout
|
|
95
|
+
#
|
|
96
|
+
# @return [Boolean] True if timeout
|
|
97
|
+
def timeout?
|
|
98
|
+
@reason == :timeout
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get error message (for backward compatibility)
|
|
102
|
+
#
|
|
103
|
+
# @return [String, nil] Error message or nil if successful
|
|
104
|
+
def error_message
|
|
105
|
+
@details
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Convert to hash
|
|
109
|
+
#
|
|
110
|
+
# @return [Hash] Result as hash
|
|
111
|
+
def to_h
|
|
112
|
+
{
|
|
113
|
+
success: @success,
|
|
114
|
+
event_id: @event_id,
|
|
115
|
+
subject: @subject,
|
|
116
|
+
reason: @reason,
|
|
117
|
+
details: @details
|
|
118
|
+
}.compact
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# String representation
|
|
122
|
+
#
|
|
123
|
+
# @return [String] Result description
|
|
124
|
+
def to_s
|
|
125
|
+
if success?
|
|
126
|
+
"PublishResult(success, event_id=#{@event_id}, subject=#{@subject})"
|
|
127
|
+
else
|
|
128
|
+
"PublishResult(failure, reason=#{@reason}, details=#{@details})"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Inspect representation
|
|
133
|
+
#
|
|
134
|
+
# @return [String] Detailed inspection
|
|
135
|
+
def inspect
|
|
136
|
+
"#<PublishResult #{success? ? 'success' : 'failure'} #{to_h.inspect}>"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# For backward compatibility - acts like boolean in conditionals
|
|
140
|
+
#
|
|
141
|
+
# @return [Boolean] Success status
|
|
142
|
+
def to_bool
|
|
143
|
+
@success
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Allow result to be used in boolean contexts
|
|
147
|
+
alias to_boolean to_bool
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
require_relative '../core/connection'
|
|
5
|
+
require_relative '../core/logging'
|
|
6
|
+
require_relative '../core/config'
|
|
7
|
+
require_relative '../core/retry_strategy'
|
|
8
|
+
require_relative '../models/model_utils'
|
|
9
|
+
require_relative 'envelope_builder'
|
|
10
|
+
require_relative 'outbox_repository'
|
|
11
|
+
require_relative 'outbox_publisher'
|
|
12
|
+
require_relative 'publish_result'
|
|
13
|
+
require_relative 'publish_argument_parser'
|
|
14
|
+
|
|
15
|
+
module NatsPubsub
|
|
16
|
+
# Publisher for PubSub events
|
|
17
|
+
# Provides a unified interface for publishing messages using either topics or domain/resource/action patterns
|
|
18
|
+
class Publisher
|
|
19
|
+
DEFAULT_RETRIES = 2
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@jts = Connection.connect!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Publish a message using one of the supported patterns:
|
|
26
|
+
# 1. Topic-based: publish(topic, message, **opts) or publish(topic:, message:, **opts)
|
|
27
|
+
# 2. Domain/resource/action: publish(domain:, resource:, action:, payload:, **opts)
|
|
28
|
+
# 3. Multi-topic: publish(topics:, message:, **opts)
|
|
29
|
+
#
|
|
30
|
+
# @return [PublishResult] Result object with success status and details
|
|
31
|
+
#
|
|
32
|
+
# @example Topic-based (positional)
|
|
33
|
+
# result = publisher.publish('orders.created', { order_id: '123' })
|
|
34
|
+
#
|
|
35
|
+
# @example Topic-based (keyword)
|
|
36
|
+
# result = publisher.publish(topic: 'orders.created', message: { order_id: '123' })
|
|
37
|
+
#
|
|
38
|
+
# @example Domain/resource/action
|
|
39
|
+
# result = publisher.publish(domain: 'orders', resource: 'order', action: 'created', payload: { id: '123' })
|
|
40
|
+
#
|
|
41
|
+
# @example Multi-topic
|
|
42
|
+
# result = publisher.publish(topics: ['orders.created', 'notifications.sent'], message: { id: '123' })
|
|
43
|
+
def publish(*args, **kwargs)
|
|
44
|
+
parse_result = PublishArgumentParser.parse(*args, **kwargs)
|
|
45
|
+
parse_result.call(self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Publish to a single topic (internal method)
|
|
49
|
+
def publish_to_topic(topic, message, **options)
|
|
50
|
+
subject = EnvelopeBuilder.build_subject(topic)
|
|
51
|
+
envelope = EnvelopeBuilder.build_topic_envelope(topic, message, options)
|
|
52
|
+
event_id = envelope['event_id']
|
|
53
|
+
|
|
54
|
+
if NatsPubsub.config.use_outbox
|
|
55
|
+
OutboxPublisher.publish(
|
|
56
|
+
subject: subject,
|
|
57
|
+
envelope: envelope,
|
|
58
|
+
event_id: event_id
|
|
59
|
+
) { with_retries { do_publish(subject, envelope, event_id) } }
|
|
60
|
+
else
|
|
61
|
+
with_retries { do_publish(subject, envelope, event_id) }
|
|
62
|
+
end
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
log_error(subject, event_id, e)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Publish using domain/resource/action pattern (internal method)
|
|
68
|
+
def publish_event(domain, resource, action, payload, **options)
|
|
69
|
+
topic = "#{domain}.#{resource}.#{action}"
|
|
70
|
+
subject = EnvelopeBuilder.build_subject(topic)
|
|
71
|
+
envelope = EnvelopeBuilder.build_event_envelope(domain, resource, action, payload, options)
|
|
72
|
+
event_id = envelope['event_id']
|
|
73
|
+
|
|
74
|
+
if NatsPubsub.config.use_outbox
|
|
75
|
+
OutboxPublisher.publish(
|
|
76
|
+
subject: subject,
|
|
77
|
+
envelope: envelope,
|
|
78
|
+
event_id: event_id
|
|
79
|
+
) { with_retries { do_publish(subject, envelope, event_id) } }
|
|
80
|
+
else
|
|
81
|
+
with_retries { do_publish(subject, envelope, event_id) }
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
log_error(subject, event_id, e)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Publish to multiple topics (internal method)
|
|
88
|
+
def publish_to_topics(topics, message, **options)
|
|
89
|
+
results = {}
|
|
90
|
+
topics.each do |topic|
|
|
91
|
+
results[topic] = publish_to_topic(topic, message, **options)
|
|
92
|
+
end
|
|
93
|
+
results
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def do_publish(subject, envelope, event_id)
|
|
99
|
+
headers = { 'nats-msg-id' => event_id }
|
|
100
|
+
|
|
101
|
+
ack = @jts.publish(subject, Oj.dump(envelope, mode: :compat), header: headers)
|
|
102
|
+
duplicate = ack.respond_to?(:duplicate?) && ack.duplicate?
|
|
103
|
+
msg = "Published #{subject} event_id=#{event_id}"
|
|
104
|
+
msg += ' (duplicate)' if duplicate
|
|
105
|
+
|
|
106
|
+
Logging.info(msg, tag: 'NatsPubsub::Publisher')
|
|
107
|
+
|
|
108
|
+
if ack.respond_to?(:error) && ack.error
|
|
109
|
+
Logging.error(
|
|
110
|
+
"Publish ack error: #{ack.error}",
|
|
111
|
+
tag: 'NatsPubsub::Publisher'
|
|
112
|
+
)
|
|
113
|
+
return PublishResult.failure(
|
|
114
|
+
reason: :publish_error,
|
|
115
|
+
details: "NATS ack error: #{ack.error}",
|
|
116
|
+
subject: subject,
|
|
117
|
+
error: ack.error
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
PublishResult.success(event_id: event_id, subject: subject)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Retry only on transient NATS IO errors using RetryStrategy
|
|
126
|
+
def with_retries(retries = DEFAULT_RETRIES)
|
|
127
|
+
result = RetryStrategy.execute(retries: retries, operation_name: 'Publish') do
|
|
128
|
+
yield
|
|
129
|
+
end
|
|
130
|
+
result
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
# Return failure result for retry errors
|
|
133
|
+
subject = e.respond_to?(:subject) ? e.subject : 'unknown'
|
|
134
|
+
event_id = e.respond_to?(:event_id) ? e.event_id : 'unknown'
|
|
135
|
+
PublishResult.failure(
|
|
136
|
+
reason: :io_error,
|
|
137
|
+
details: "Retries exhausted: #{e.class} - #{e.message}",
|
|
138
|
+
subject: subject,
|
|
139
|
+
error: e
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def log_error(subject, event_id, exc)
|
|
144
|
+
Logging.error(
|
|
145
|
+
"Publish failed: #{exc.class} #{exc.message}",
|
|
146
|
+
tag: 'NatsPubsub::Publisher'
|
|
147
|
+
)
|
|
148
|
+
PublishResult.failure(
|
|
149
|
+
reason: :exception,
|
|
150
|
+
details: "#{exc.class}: #{exc.message}",
|
|
151
|
+
subject: subject,
|
|
152
|
+
error: exc
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
module Rails
|
|
5
|
+
# Rails helper for NatsPubsub health check endpoints
|
|
6
|
+
#
|
|
7
|
+
# Provides easy integration with Rails routing and controllers.
|
|
8
|
+
#
|
|
9
|
+
# @example Rails routes
|
|
10
|
+
# # config/routes.rb
|
|
11
|
+
# require 'nats_pubsub/rails/health_endpoint'
|
|
12
|
+
#
|
|
13
|
+
# Rails.application.routes.draw do
|
|
14
|
+
# mount NatsPubsub::Rails::HealthEndpoint => '/nats-health'
|
|
15
|
+
#
|
|
16
|
+
# # Or use individual endpoints
|
|
17
|
+
# get '/health/nats', to: NatsPubsub::Rails::HealthEndpoint.full_check
|
|
18
|
+
# get '/health/nats/quick', to: NatsPubsub::Rails::HealthEndpoint.quick_check
|
|
19
|
+
# get '/health/nats/liveness', to: NatsPubsub::Rails::HealthEndpoint.liveness
|
|
20
|
+
# get '/health/nats/readiness', to: NatsPubsub::Rails::HealthEndpoint.readiness
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example Controller action
|
|
24
|
+
# class HealthController < ApplicationController
|
|
25
|
+
# def nats
|
|
26
|
+
# render json: NatsPubsub::Rails::HealthEndpoint.check_health
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
class HealthEndpoint
|
|
31
|
+
# Rack application for health check endpoints
|
|
32
|
+
#
|
|
33
|
+
# Supports multiple endpoints:
|
|
34
|
+
# - GET / - Full health check
|
|
35
|
+
# - GET /quick - Quick connection check
|
|
36
|
+
# - GET /liveness - Liveness probe (always returns 200 if app is running)
|
|
37
|
+
# - GET /readiness - Readiness probe (checks if ready to accept traffic)
|
|
38
|
+
#
|
|
39
|
+
# @param env [Hash] Rack environment
|
|
40
|
+
# @return [Array] Rack response [status, headers, body]
|
|
41
|
+
def self.call(env)
|
|
42
|
+
request = ::Rack::Request.new(env)
|
|
43
|
+
|
|
44
|
+
case request.path_info
|
|
45
|
+
when '/', ''
|
|
46
|
+
full_check.call(env)
|
|
47
|
+
when '/quick'
|
|
48
|
+
quick_check.call(env)
|
|
49
|
+
when '/liveness'
|
|
50
|
+
liveness.call(env)
|
|
51
|
+
when '/readiness'
|
|
52
|
+
readiness.call(env)
|
|
53
|
+
else
|
|
54
|
+
not_found
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Full health check endpoint
|
|
59
|
+
#
|
|
60
|
+
# @return [Proc] Rack endpoint
|
|
61
|
+
def self.full_check
|
|
62
|
+
lambda do |_env|
|
|
63
|
+
result = Core::HealthCheck.check
|
|
64
|
+
status_code = http_status_for_health(result)
|
|
65
|
+
|
|
66
|
+
[
|
|
67
|
+
status_code,
|
|
68
|
+
{ 'Content-Type' => 'application/json' },
|
|
69
|
+
[result.to_json]
|
|
70
|
+
]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Quick health check endpoint (connection only)
|
|
75
|
+
#
|
|
76
|
+
# @return [Proc] Rack endpoint
|
|
77
|
+
def self.quick_check
|
|
78
|
+
lambda do |_env|
|
|
79
|
+
result = Core::HealthCheck.quick_check
|
|
80
|
+
status_code = result.healthy? ? 200 : 503
|
|
81
|
+
|
|
82
|
+
[
|
|
83
|
+
status_code,
|
|
84
|
+
{ 'Content-Type' => 'application/json' },
|
|
85
|
+
[result.to_json]
|
|
86
|
+
]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Liveness probe endpoint (Kubernetes-style)
|
|
91
|
+
#
|
|
92
|
+
# Always returns 200 if the application is running.
|
|
93
|
+
# Used to determine if the application should be restarted.
|
|
94
|
+
#
|
|
95
|
+
# @return [Proc] Rack endpoint
|
|
96
|
+
def self.liveness
|
|
97
|
+
lambda do |_env|
|
|
98
|
+
response = {
|
|
99
|
+
status: 'alive',
|
|
100
|
+
timestamp: Time.now.iso8601
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
[
|
|
104
|
+
200,
|
|
105
|
+
{ 'Content-Type' => 'application/json' },
|
|
106
|
+
[response.to_json]
|
|
107
|
+
]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Readiness probe endpoint (Kubernetes-style)
|
|
112
|
+
#
|
|
113
|
+
# Returns 200 if the application is ready to accept traffic.
|
|
114
|
+
# Checks NATS connection and basic connectivity.
|
|
115
|
+
#
|
|
116
|
+
# @return [Proc] Rack endpoint
|
|
117
|
+
def self.readiness
|
|
118
|
+
lambda do |_env|
|
|
119
|
+
result = Core::HealthCheck.quick_check
|
|
120
|
+
|
|
121
|
+
response = {
|
|
122
|
+
status: result.healthy? ? 'ready' : 'not_ready',
|
|
123
|
+
healthy: result.healthy?,
|
|
124
|
+
components: result.components,
|
|
125
|
+
timestamp: Time.now.iso8601
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
status_code = result.healthy? ? 200 : 503
|
|
129
|
+
|
|
130
|
+
[
|
|
131
|
+
status_code,
|
|
132
|
+
{ 'Content-Type' => 'application/json' },
|
|
133
|
+
[response.to_json]
|
|
134
|
+
]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Check health and return hash (for controller use)
|
|
139
|
+
#
|
|
140
|
+
# @return [Hash] Health check result
|
|
141
|
+
def self.check_health
|
|
142
|
+
Core::HealthCheck.check.to_h
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Check quick health and return hash (for controller use)
|
|
146
|
+
#
|
|
147
|
+
# @return [Hash] Quick health check result
|
|
148
|
+
def self.quick_health
|
|
149
|
+
Core::HealthCheck.quick_check.to_h
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Helper for Rails controller
|
|
153
|
+
#
|
|
154
|
+
# @example
|
|
155
|
+
# class HealthController < ApplicationController
|
|
156
|
+
# include NatsPubsub::Rails::HealthEndpoint::ControllerHelper
|
|
157
|
+
#
|
|
158
|
+
# def nats
|
|
159
|
+
# render_nats_health
|
|
160
|
+
# end
|
|
161
|
+
#
|
|
162
|
+
# def nats_quick
|
|
163
|
+
# render_nats_health_quick
|
|
164
|
+
# end
|
|
165
|
+
# end
|
|
166
|
+
module ControllerHelper
|
|
167
|
+
# Render full health check
|
|
168
|
+
def render_nats_health
|
|
169
|
+
result = Core::HealthCheck.check
|
|
170
|
+
status_code = HealthEndpoint.http_status_for_health(result)
|
|
171
|
+
|
|
172
|
+
render json: result.to_h, status: status_code
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Render quick health check
|
|
176
|
+
def render_nats_health_quick
|
|
177
|
+
result = Core::HealthCheck.quick_check
|
|
178
|
+
status_code = result.healthy? ? 200 : 503
|
|
179
|
+
|
|
180
|
+
render json: result.to_h, status: status_code
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Render liveness check
|
|
184
|
+
def render_nats_liveness
|
|
185
|
+
render json: {
|
|
186
|
+
status: 'alive',
|
|
187
|
+
timestamp: Time.now.iso8601
|
|
188
|
+
}, status: 200
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Render readiness check
|
|
192
|
+
def render_nats_readiness
|
|
193
|
+
result = Core::HealthCheck.quick_check
|
|
194
|
+
|
|
195
|
+
render json: {
|
|
196
|
+
status: result.healthy? ? 'ready' : 'not_ready',
|
|
197
|
+
healthy: result.healthy?,
|
|
198
|
+
components: result.components,
|
|
199
|
+
timestamp: Time.now.iso8601
|
|
200
|
+
}, status: (result.healthy? ? 200 : 503)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
class << self
|
|
205
|
+
# Determine HTTP status code from health result
|
|
206
|
+
#
|
|
207
|
+
# @param result [Core::HealthCheck::Result] Health check result
|
|
208
|
+
# @return [Integer] HTTP status code
|
|
209
|
+
def http_status_for_health(result)
|
|
210
|
+
case result.status
|
|
211
|
+
when :healthy
|
|
212
|
+
200
|
|
213
|
+
when :degraded
|
|
214
|
+
200 # Return 200 for degraded but still functional
|
|
215
|
+
when :unhealthy
|
|
216
|
+
503
|
|
217
|
+
else
|
|
218
|
+
503
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def not_found
|
|
225
|
+
response = {
|
|
226
|
+
error: 'Not Found',
|
|
227
|
+
available_paths: ['/', '/quick', '/liveness', '/readiness']
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
[
|
|
231
|
+
404,
|
|
232
|
+
{ 'Content-Type' => 'application/json' },
|
|
233
|
+
[response.to_json]
|
|
234
|
+
]
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'models/model_codec_setup'
|
|
4
|
+
|
|
5
|
+
module NatsPubsub
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
# Configuration before Rails initialization
|
|
8
|
+
config.before_configuration do
|
|
9
|
+
# Set default configuration from environment
|
|
10
|
+
NatsPubsub.configure do |config|
|
|
11
|
+
config.env = ENV.fetch('RAILS_ENV', 'development')
|
|
12
|
+
config.app_name = Rails.application.class.module_parent_name.underscore
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Model codec setup after ActiveRecord loads
|
|
17
|
+
initializer 'nats_pubsub.defer_model_tweaks', after: :active_record do
|
|
18
|
+
ActiveSupport.on_load(:active_record) do
|
|
19
|
+
ActiveSupport::Reloader.to_prepare do
|
|
20
|
+
NatsPubsub::ModelCodecSetup.apply!
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Validate configuration after initialization
|
|
26
|
+
initializer 'nats_pubsub.validate_config', after: :load_config_initializers do
|
|
27
|
+
Rails.application.config.after_initialize do
|
|
28
|
+
next unless NatsPubsub.configuration
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
NatsPubsub.configuration.validate!
|
|
32
|
+
rescue NatsPubsub::ConfigurationError => e
|
|
33
|
+
Rails.logger.warn "[NatsPubsub] Configuration warning: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Auto-discover subscribers in development
|
|
39
|
+
initializer 'nats_pubsub.auto_discover_subscribers' do
|
|
40
|
+
Rails.application.config.to_prepare do
|
|
41
|
+
if Rails.env.development? || Rails.env.test?
|
|
42
|
+
NatsPubsub::Subscribers::Registry.instance.discover_subscribers!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Load rake tasks
|
|
48
|
+
rake_tasks do
|
|
49
|
+
load File.expand_path('tasks/install.rake', __dir__)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'base64'
|
|
6
|
+
require_relative '../core/logging'
|
|
7
|
+
require_relative '../core/config'
|
|
8
|
+
|
|
9
|
+
module NatsPubsub
|
|
10
|
+
module Subscribers
|
|
11
|
+
class DlqHandler
|
|
12
|
+
def initialize(jts)
|
|
13
|
+
@jts = jts
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Publishes failed message to Dead Letter Queue with explanatory headers/context
|
|
17
|
+
#
|
|
18
|
+
# @param msg [NATS::Msg] NATS message object
|
|
19
|
+
# @param ctx [MessageContext] Message context
|
|
20
|
+
# @param error_context [ErrorContext] Error context with failure details
|
|
21
|
+
# @return [Boolean] True if published successfully, false otherwise
|
|
22
|
+
def publish_to_dlq(msg, ctx, error_context:)
|
|
23
|
+
unless NatsPubsub.config.use_dlq
|
|
24
|
+
Logging.warn("DLQ disabled; skipping publish for event_id=#{ctx.event_id}", tag: 'NatsPubsub::Subscribers::DlqHandler')
|
|
25
|
+
return false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
raw_base64 = Base64.strict_encode64(msg.data.to_s)
|
|
29
|
+
envelope = build_envelope(ctx, error_context, raw_base64)
|
|
30
|
+
headers = build_headers(msg.header, error_context.reason, ctx.deliveries, envelope)
|
|
31
|
+
@jts.publish(NatsPubsub.config.dlq_subject, msg.data, header: headers)
|
|
32
|
+
true
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
Logging.error(
|
|
35
|
+
"DLQ publish failed event_id=#{ctx.event_id}: #{e.class} #{e.message}",
|
|
36
|
+
tag: 'NatsPubsub::Subscribers::DlqHandler'
|
|
37
|
+
)
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_envelope(ctx, error_context, raw_base64)
|
|
44
|
+
{
|
|
45
|
+
event_id: ctx.event_id,
|
|
46
|
+
reason: error_context.reason,
|
|
47
|
+
error_class: error_context.error_class,
|
|
48
|
+
error_message: error_context.error_message,
|
|
49
|
+
deliveries: ctx.deliveries,
|
|
50
|
+
original_subject: ctx.subject,
|
|
51
|
+
sequence: ctx.seq,
|
|
52
|
+
consumer: ctx.consumer,
|
|
53
|
+
stream: ctx.stream,
|
|
54
|
+
published_at: Time.now.utc.iso8601,
|
|
55
|
+
raw_base64: raw_base64
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_headers(original_headers, reason, deliveries, envelope)
|
|
60
|
+
headers = (original_headers || {}).dup
|
|
61
|
+
headers['x-dead-letter'] = 'true'
|
|
62
|
+
headers['x-dlq-reason'] = reason
|
|
63
|
+
headers['x-deliveries'] = deliveries.to_s
|
|
64
|
+
headers['x-dlq-context'] = Oj.dump(envelope, mode: :compat)
|
|
65
|
+
headers
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|