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,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
module Core
|
|
5
|
+
# Unified message context
|
|
6
|
+
#
|
|
7
|
+
# Consolidates all message metadata into a single, well-typed context object.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute [r] event_id
|
|
10
|
+
# @return [String] Unique event identifier (UUID)
|
|
11
|
+
# @!attribute [r] subject
|
|
12
|
+
# @return [String] Full NATS subject
|
|
13
|
+
# @!attribute [r] topic
|
|
14
|
+
# @return [String] Extracted topic from subject
|
|
15
|
+
# @!attribute [r] trace_id
|
|
16
|
+
# @return [String, nil] Optional distributed tracing ID
|
|
17
|
+
# @!attribute [r] correlation_id
|
|
18
|
+
# @return [String, nil] Optional correlation ID for request tracking
|
|
19
|
+
# @!attribute [r] occurred_at
|
|
20
|
+
# @return [Time] Timestamp when the event occurred
|
|
21
|
+
# @!attribute [r] deliveries
|
|
22
|
+
# @return [Integer] Number of delivery attempts
|
|
23
|
+
# @!attribute [r] stream
|
|
24
|
+
# @return [String, nil] JetStream stream name
|
|
25
|
+
# @!attribute [r] stream_seq
|
|
26
|
+
# @return [Integer, nil] JetStream stream sequence number
|
|
27
|
+
# @!attribute [r] producer
|
|
28
|
+
# @return [String, nil] Application that produced the event
|
|
29
|
+
# @!attribute [r] domain
|
|
30
|
+
# @return [String, nil] Legacy: domain field (for backward compatibility)
|
|
31
|
+
# @!attribute [r] resource
|
|
32
|
+
# @return [String, nil] Legacy: resource field (for backward compatibility)
|
|
33
|
+
# @!attribute [r] action
|
|
34
|
+
# @return [String, nil] Legacy: action field (for backward compatibility)
|
|
35
|
+
#
|
|
36
|
+
# @example Using in a subscriber
|
|
37
|
+
# class EmailSubscriber < NatsPubsub::Subscriber
|
|
38
|
+
# subscribe_to 'notifications.email'
|
|
39
|
+
#
|
|
40
|
+
# def handle(message, context)
|
|
41
|
+
# puts "Processing event #{context.event_id}"
|
|
42
|
+
# puts "Trace ID: #{context.trace_id}"
|
|
43
|
+
# puts "Delivery attempt: #{context.deliveries}"
|
|
44
|
+
# end
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
class MessageContext
|
|
48
|
+
attr_reader :event_id, :subject, :topic, :trace_id, :correlation_id,
|
|
49
|
+
:occurred_at, :deliveries, :stream, :stream_seq, :producer,
|
|
50
|
+
:domain, :resource, :action
|
|
51
|
+
|
|
52
|
+
# Initialize a new message context
|
|
53
|
+
#
|
|
54
|
+
# @param event_id [String] Unique event identifier
|
|
55
|
+
# @param subject [String] Full NATS subject
|
|
56
|
+
# @param topic [String] Extracted topic
|
|
57
|
+
# @param trace_id [String, nil] Distributed tracing ID
|
|
58
|
+
# @param correlation_id [String, nil] Request correlation ID
|
|
59
|
+
# @param occurred_at [Time] Event timestamp
|
|
60
|
+
# @param deliveries [Integer] Number of delivery attempts
|
|
61
|
+
# @param stream [String, nil] JetStream stream name
|
|
62
|
+
# @param stream_seq [Integer, nil] JetStream stream sequence
|
|
63
|
+
# @param producer [String, nil] Producer application name
|
|
64
|
+
# @param domain [String, nil] Legacy domain field
|
|
65
|
+
# @param resource [String, nil] Legacy resource field
|
|
66
|
+
# @param action [String, nil] Legacy action field
|
|
67
|
+
def initialize(
|
|
68
|
+
event_id:,
|
|
69
|
+
subject:,
|
|
70
|
+
topic:,
|
|
71
|
+
trace_id: nil,
|
|
72
|
+
correlation_id: nil,
|
|
73
|
+
occurred_at:,
|
|
74
|
+
deliveries:,
|
|
75
|
+
stream: nil,
|
|
76
|
+
stream_seq: nil,
|
|
77
|
+
producer: nil,
|
|
78
|
+
domain: nil,
|
|
79
|
+
resource: nil,
|
|
80
|
+
action: nil
|
|
81
|
+
)
|
|
82
|
+
@event_id = event_id
|
|
83
|
+
@subject = subject
|
|
84
|
+
@topic = topic
|
|
85
|
+
@trace_id = trace_id
|
|
86
|
+
@correlation_id = correlation_id
|
|
87
|
+
@occurred_at = occurred_at
|
|
88
|
+
@deliveries = deliveries
|
|
89
|
+
@stream = stream
|
|
90
|
+
@stream_seq = stream_seq
|
|
91
|
+
@producer = producer
|
|
92
|
+
@domain = domain
|
|
93
|
+
@resource = resource
|
|
94
|
+
@action = action
|
|
95
|
+
|
|
96
|
+
freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Create context from legacy metadata hash
|
|
100
|
+
#
|
|
101
|
+
# @param metadata [Hash] Legacy metadata hash
|
|
102
|
+
# @return [MessageContext] New context instance
|
|
103
|
+
#
|
|
104
|
+
# @example
|
|
105
|
+
# context = MessageContext.from_metadata(metadata)
|
|
106
|
+
#
|
|
107
|
+
def self.from_metadata(metadata)
|
|
108
|
+
# Extract topic from subject
|
|
109
|
+
subject = metadata[:subject] || metadata['subject']
|
|
110
|
+
topic = extract_topic_from_subject(subject)
|
|
111
|
+
|
|
112
|
+
new(
|
|
113
|
+
event_id: metadata[:event_id] || metadata['event_id'],
|
|
114
|
+
subject: subject,
|
|
115
|
+
topic: topic,
|
|
116
|
+
trace_id: metadata[:trace_id] || metadata['trace_id'],
|
|
117
|
+
correlation_id: metadata[:correlation_id] || metadata['correlation_id'],
|
|
118
|
+
occurred_at: parse_time(metadata[:occurred_at] || metadata['occurred_at']),
|
|
119
|
+
deliveries: metadata[:deliveries] || metadata['deliveries'] || 1,
|
|
120
|
+
stream: metadata[:stream] || metadata['stream'],
|
|
121
|
+
stream_seq: metadata[:stream_seq] || metadata['stream_seq'],
|
|
122
|
+
producer: metadata[:producer] || metadata['producer'],
|
|
123
|
+
domain: metadata[:domain] || metadata['domain'],
|
|
124
|
+
resource: metadata[:resource] || metadata['resource'],
|
|
125
|
+
action: metadata[:action] || metadata['action']
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Convert to hash
|
|
130
|
+
#
|
|
131
|
+
# @return [Hash] Hash representation
|
|
132
|
+
def to_h
|
|
133
|
+
{
|
|
134
|
+
event_id: event_id,
|
|
135
|
+
subject: subject,
|
|
136
|
+
topic: topic,
|
|
137
|
+
trace_id: trace_id,
|
|
138
|
+
correlation_id: correlation_id,
|
|
139
|
+
occurred_at: occurred_at,
|
|
140
|
+
deliveries: deliveries,
|
|
141
|
+
stream: stream,
|
|
142
|
+
stream_seq: stream_seq,
|
|
143
|
+
producer: producer,
|
|
144
|
+
domain: domain,
|
|
145
|
+
resource: resource,
|
|
146
|
+
action: action
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
alias to_hash to_h
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# Extract topic from NATS subject
|
|
155
|
+
#
|
|
156
|
+
# @param subject [String] Full NATS subject
|
|
157
|
+
# @return [String] Extracted topic
|
|
158
|
+
#
|
|
159
|
+
# @example
|
|
160
|
+
# extract_topic_from_subject('production.myapp.notifications.email')
|
|
161
|
+
# # => 'notifications.email'
|
|
162
|
+
#
|
|
163
|
+
def self.extract_topic_from_subject(subject)
|
|
164
|
+
return '' if subject.nil? || subject.empty?
|
|
165
|
+
|
|
166
|
+
parts = subject.split('.')
|
|
167
|
+
# Remove env and app_name (first two parts)
|
|
168
|
+
parts[2..-1]&.join('.') || ''
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Parse time from various formats
|
|
172
|
+
#
|
|
173
|
+
# @param value [Time, String, Integer, nil] Time value
|
|
174
|
+
# @return [Time] Parsed time
|
|
175
|
+
def self.parse_time(value)
|
|
176
|
+
case value
|
|
177
|
+
when Time
|
|
178
|
+
value
|
|
179
|
+
when String
|
|
180
|
+
Time.parse(value)
|
|
181
|
+
when Integer
|
|
182
|
+
Time.at(value)
|
|
183
|
+
else
|
|
184
|
+
Time.now
|
|
185
|
+
end
|
|
186
|
+
rescue StandardError
|
|
187
|
+
Time.now
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private_class_method :extract_topic_from_subject, :parse_time
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
module Core
|
|
5
|
+
# Configuration presets for common environments
|
|
6
|
+
#
|
|
7
|
+
# Provides pre-configured settings optimized for different deployment scenarios.
|
|
8
|
+
#
|
|
9
|
+
# @example Development preset
|
|
10
|
+
# NatsPubsub.configure do |config|
|
|
11
|
+
# Presets.development(config, app_name: 'my-service')
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Production preset
|
|
15
|
+
# NatsPubsub.configure do |config|
|
|
16
|
+
# Presets.production(
|
|
17
|
+
# config,
|
|
18
|
+
# app_name: 'my-service',
|
|
19
|
+
# nats_urls: ENV.fetch('NATS_CLUSTER_URLS').split(',')
|
|
20
|
+
# )
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
module Presets
|
|
24
|
+
# Development preset optimized for local development
|
|
25
|
+
#
|
|
26
|
+
# Features:
|
|
27
|
+
# - Lower concurrency for easier debugging
|
|
28
|
+
# - Shorter timeouts for faster feedback
|
|
29
|
+
# - DLQ enabled for error visibility
|
|
30
|
+
# - Debug logging
|
|
31
|
+
#
|
|
32
|
+
# @param config [Config] Configuration object
|
|
33
|
+
# @param options [Hash] Additional options
|
|
34
|
+
# @option options [String] :app_name Application name (required)
|
|
35
|
+
# @option options [String, Array<String>] :nats_urls NATS server URLs (default: 'nats://localhost:4222')
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# NatsPubsub.configure do |config|
|
|
39
|
+
# Presets.development(config, app_name: 'my-service')
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
def self.development(config, **options)
|
|
43
|
+
validate_required_options!(options, :app_name)
|
|
44
|
+
|
|
45
|
+
config.app_name = options[:app_name]
|
|
46
|
+
config.nats_urls = options.fetch(:nats_urls, 'nats://localhost:4222')
|
|
47
|
+
config.env = options.fetch(:env, 'development')
|
|
48
|
+
|
|
49
|
+
# Lower concurrency for easier debugging
|
|
50
|
+
config.concurrency = 5
|
|
51
|
+
|
|
52
|
+
# Faster feedback during development
|
|
53
|
+
config.max_deliver = 3
|
|
54
|
+
config.ack_wait = 15_000 # 15 seconds
|
|
55
|
+
config.backoff = [1_000, 3_000, 5_000] # Shorter backoff
|
|
56
|
+
|
|
57
|
+
# DLQ enabled for visibility
|
|
58
|
+
config.use_dlq = true
|
|
59
|
+
|
|
60
|
+
# Inbox/Outbox typically not needed in dev
|
|
61
|
+
config.use_inbox = false
|
|
62
|
+
config.use_outbox = false
|
|
63
|
+
|
|
64
|
+
config
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Production preset optimized for reliability and performance
|
|
68
|
+
#
|
|
69
|
+
# Features:
|
|
70
|
+
# - Higher concurrency for throughput
|
|
71
|
+
# - Longer timeouts for network latency
|
|
72
|
+
# - Aggressive retry strategy
|
|
73
|
+
# - DLQ enabled for operational safety
|
|
74
|
+
# - Info-level logging
|
|
75
|
+
#
|
|
76
|
+
# @param config [Config] Configuration object
|
|
77
|
+
# @param options [Hash] Additional options
|
|
78
|
+
# @option options [String] :app_name Application name (required)
|
|
79
|
+
# @option options [String, Array<String>] :nats_urls NATS server URLs (required)
|
|
80
|
+
#
|
|
81
|
+
# @example
|
|
82
|
+
# NatsPubsub.configure do |config|
|
|
83
|
+
# Presets.production(
|
|
84
|
+
# config,
|
|
85
|
+
# app_name: 'my-service',
|
|
86
|
+
# nats_urls: ENV.fetch('NATS_CLUSTER_URLS').split(',')
|
|
87
|
+
# )
|
|
88
|
+
# end
|
|
89
|
+
#
|
|
90
|
+
def self.production(config, **options)
|
|
91
|
+
validate_required_options!(options, :app_name, :nats_urls)
|
|
92
|
+
|
|
93
|
+
config.app_name = options[:app_name]
|
|
94
|
+
config.nats_urls = options[:nats_urls]
|
|
95
|
+
config.env = options.fetch(:env, 'production')
|
|
96
|
+
|
|
97
|
+
# Higher concurrency for throughput
|
|
98
|
+
config.concurrency = 20
|
|
99
|
+
|
|
100
|
+
# More aggressive retry strategy
|
|
101
|
+
config.max_deliver = 5
|
|
102
|
+
config.ack_wait = 30_000 # 30 seconds
|
|
103
|
+
config.backoff = [1_000, 5_000, 15_000, 30_000, 60_000] # Exponential backoff
|
|
104
|
+
|
|
105
|
+
# DLQ enabled for operational safety
|
|
106
|
+
config.use_dlq = true
|
|
107
|
+
|
|
108
|
+
# Consider enabling for transactional guarantees
|
|
109
|
+
config.use_inbox = options.fetch(:use_inbox, false)
|
|
110
|
+
config.use_outbox = options.fetch(:use_outbox, false)
|
|
111
|
+
|
|
112
|
+
config
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Staging preset balanced between development and production
|
|
116
|
+
#
|
|
117
|
+
# Features:
|
|
118
|
+
# - Moderate concurrency
|
|
119
|
+
# - Production-like retry strategy
|
|
120
|
+
# - DLQ enabled
|
|
121
|
+
# - Debug logging for troubleshooting
|
|
122
|
+
#
|
|
123
|
+
# @param config [Config] Configuration object
|
|
124
|
+
# @param options [Hash] Additional options
|
|
125
|
+
# @option options [String] :app_name Application name (required)
|
|
126
|
+
# @option options [String, Array<String>] :nats_urls NATS server URLs (required)
|
|
127
|
+
#
|
|
128
|
+
# @example
|
|
129
|
+
# NatsPubsub.configure do |config|
|
|
130
|
+
# Presets.staging(
|
|
131
|
+
# config,
|
|
132
|
+
# app_name: 'my-service',
|
|
133
|
+
# nats_urls: 'nats://staging-nats:4222'
|
|
134
|
+
# )
|
|
135
|
+
# end
|
|
136
|
+
#
|
|
137
|
+
def self.staging(config, **options)
|
|
138
|
+
validate_required_options!(options, :app_name, :nats_urls)
|
|
139
|
+
|
|
140
|
+
config.app_name = options[:app_name]
|
|
141
|
+
config.nats_urls = options[:nats_urls]
|
|
142
|
+
config.env = options.fetch(:env, 'staging')
|
|
143
|
+
|
|
144
|
+
# Moderate concurrency
|
|
145
|
+
config.concurrency = 10
|
|
146
|
+
|
|
147
|
+
# Production-like retry strategy
|
|
148
|
+
config.max_deliver = 5
|
|
149
|
+
config.ack_wait = 30_000
|
|
150
|
+
config.backoff = [1_000, 5_000, 15_000, 30_000, 60_000]
|
|
151
|
+
|
|
152
|
+
# DLQ enabled
|
|
153
|
+
config.use_dlq = true
|
|
154
|
+
|
|
155
|
+
# Inbox/Outbox optional
|
|
156
|
+
config.use_inbox = options.fetch(:use_inbox, false)
|
|
157
|
+
config.use_outbox = options.fetch(:use_outbox, false)
|
|
158
|
+
|
|
159
|
+
config
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Testing preset optimized for unit and integration tests
|
|
163
|
+
#
|
|
164
|
+
# Features:
|
|
165
|
+
# - Minimal concurrency
|
|
166
|
+
# - Very short timeouts for fast tests
|
|
167
|
+
# - No retries for deterministic behavior
|
|
168
|
+
# - DLQ disabled
|
|
169
|
+
#
|
|
170
|
+
# @param config [Config] Configuration object
|
|
171
|
+
# @param options [Hash] Additional options
|
|
172
|
+
# @option options [String] :app_name Application name (required)
|
|
173
|
+
# @option options [String, Array<String>] :nats_urls NATS server URLs (default: 'nats://localhost:4222')
|
|
174
|
+
#
|
|
175
|
+
# @example
|
|
176
|
+
# NatsPubsub.configure do |config|
|
|
177
|
+
# Presets.testing(config, app_name: 'test-service')
|
|
178
|
+
# end
|
|
179
|
+
#
|
|
180
|
+
def self.testing(config, **options)
|
|
181
|
+
validate_required_options!(options, :app_name)
|
|
182
|
+
|
|
183
|
+
config.app_name = options[:app_name]
|
|
184
|
+
config.nats_urls = options.fetch(:nats_urls, 'nats://localhost:4222')
|
|
185
|
+
config.env = options.fetch(:env, 'test')
|
|
186
|
+
|
|
187
|
+
# Minimal concurrency
|
|
188
|
+
config.concurrency = 1
|
|
189
|
+
|
|
190
|
+
# No retries for deterministic behavior
|
|
191
|
+
config.max_deliver = 1
|
|
192
|
+
config.ack_wait = 5_000 # 5 seconds
|
|
193
|
+
config.backoff = []
|
|
194
|
+
|
|
195
|
+
# DLQ disabled for simpler testing
|
|
196
|
+
config.use_dlq = false
|
|
197
|
+
|
|
198
|
+
# Inbox/Outbox disabled
|
|
199
|
+
config.use_inbox = false
|
|
200
|
+
config.use_outbox = false
|
|
201
|
+
|
|
202
|
+
config
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Validate required options
|
|
206
|
+
#
|
|
207
|
+
# @param options [Hash] Options hash
|
|
208
|
+
# @param required [Array<Symbol>] Required option keys
|
|
209
|
+
# @raise [ArgumentError] If required options are missing
|
|
210
|
+
#
|
|
211
|
+
# @api private
|
|
212
|
+
def self.validate_required_options!(options, *required)
|
|
213
|
+
missing = required - options.keys
|
|
214
|
+
return if missing.empty?
|
|
215
|
+
|
|
216
|
+
raise ArgumentError, "Missing required options: #{missing.join(', ')}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private_class_method :validate_required_options!
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nats/io/client'
|
|
4
|
+
require_relative 'logging'
|
|
5
|
+
|
|
6
|
+
module NatsPubsub
|
|
7
|
+
# Handles retry logic with exponential backoff for operations.
|
|
8
|
+
# Extracted from Publisher to follow Single Responsibility Principle.
|
|
9
|
+
class RetryStrategy
|
|
10
|
+
DEFAULT_RETRIES = 3
|
|
11
|
+
|
|
12
|
+
# Build list of retriable errors dynamically to handle different NATS versions
|
|
13
|
+
RETRIABLE_ERRORS = begin
|
|
14
|
+
errors = [NATS::IO::Timeout, NATS::IO::Error]
|
|
15
|
+
errors << NATS::IO::NoServersError if defined?(NATS::IO::NoServersError)
|
|
16
|
+
errors << NATS::IO::StaleConnectionError if defined?(NATS::IO::StaleConnectionError)
|
|
17
|
+
errors << NATS::IO::SocketTimeoutError if defined?(NATS::IO::SocketTimeoutError)
|
|
18
|
+
errors.freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Execute a block with retry logic
|
|
22
|
+
#
|
|
23
|
+
# @param retries [Integer] Number of retry attempts
|
|
24
|
+
# @param operation_name [String] Name of the operation for logging
|
|
25
|
+
# @yield Block to execute with retries
|
|
26
|
+
# @return [Object] Result of the block
|
|
27
|
+
# @raise [StandardError] If all retries are exhausted
|
|
28
|
+
def self.execute(retries: DEFAULT_RETRIES, operation_name: 'operation', &block)
|
|
29
|
+
new(retries: retries, operation_name: operation_name).execute(&block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(retries: DEFAULT_RETRIES, operation_name: 'operation')
|
|
33
|
+
@retries = retries
|
|
34
|
+
@operation_name = operation_name
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute
|
|
38
|
+
attempt = 0
|
|
39
|
+
begin
|
|
40
|
+
yield
|
|
41
|
+
rescue *RETRIABLE_ERRORS => e
|
|
42
|
+
attempt += 1
|
|
43
|
+
if attempt <= @retries
|
|
44
|
+
backoff_time = calculate_backoff(attempt)
|
|
45
|
+
Logging.warn(
|
|
46
|
+
"#{@operation_name} failed (attempt #{attempt}/#{@retries}): #{e.class} #{e.message}. " \
|
|
47
|
+
"Retrying in #{backoff_time}s...",
|
|
48
|
+
tag: 'NatsPubsub::RetryStrategy'
|
|
49
|
+
)
|
|
50
|
+
sleep backoff_time
|
|
51
|
+
retry
|
|
52
|
+
end
|
|
53
|
+
Logging.error(
|
|
54
|
+
"#{@operation_name} failed after #{@retries} retries: #{e.class} #{e.message}",
|
|
55
|
+
tag: 'NatsPubsub::RetryStrategy'
|
|
56
|
+
)
|
|
57
|
+
raise
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Calculate exponential backoff time
|
|
64
|
+
# @param attempt [Integer] Current attempt number
|
|
65
|
+
# @return [Float] Sleep duration in seconds
|
|
66
|
+
def calculate_backoff(attempt)
|
|
67
|
+
# Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, etc.
|
|
68
|
+
[0.1 * (2**(attempt - 1)), 5.0].min # Cap at 5 seconds
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module NatsPubsub
|
|
7
|
+
module Core
|
|
8
|
+
# Structured logger for machine-parseable JSON logs
|
|
9
|
+
# Provides consistent logging format with correlation IDs and metadata
|
|
10
|
+
class StructuredLogger
|
|
11
|
+
LEVELS = {
|
|
12
|
+
debug: 0,
|
|
13
|
+
info: 1,
|
|
14
|
+
warn: 2,
|
|
15
|
+
error: 3,
|
|
16
|
+
fatal: 4
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :output, :level, :context
|
|
20
|
+
|
|
21
|
+
# Initialize a new structured logger
|
|
22
|
+
#
|
|
23
|
+
# @param output [IO] Output stream (default: $stdout)
|
|
24
|
+
# @param level [Symbol] Log level (:debug, :info, :warn, :error, :fatal)
|
|
25
|
+
# @param context [Hash] Base context included in all log entries
|
|
26
|
+
def initialize(output: $stdout, level: :info, context: {})
|
|
27
|
+
@output = output
|
|
28
|
+
@level = normalize_level(level)
|
|
29
|
+
@context = context.transform_keys(&:to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Log at debug level
|
|
33
|
+
def debug(message, metadata = {})
|
|
34
|
+
log(:debug, message, metadata)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Log at info level
|
|
38
|
+
def info(message, metadata = {})
|
|
39
|
+
log(:info, message, metadata)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Log at warn level
|
|
43
|
+
def warn(message, metadata = {})
|
|
44
|
+
log(:warn, message, metadata)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Log at error level
|
|
48
|
+
def error(message, metadata = {})
|
|
49
|
+
log(:error, message, metadata)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Log at fatal level
|
|
53
|
+
def fatal(message, metadata = {})
|
|
54
|
+
log(:fatal, message, metadata)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Create child logger with additional context
|
|
58
|
+
#
|
|
59
|
+
# @param child_context [Hash] Additional context
|
|
60
|
+
# @return [StructuredLogger] New logger with merged context
|
|
61
|
+
def with_context(child_context)
|
|
62
|
+
self.class.new(
|
|
63
|
+
output: output,
|
|
64
|
+
level: level,
|
|
65
|
+
context: context.merge(child_context.transform_keys(&:to_s))
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Log a message
|
|
72
|
+
def log(severity, message, metadata)
|
|
73
|
+
return if LEVELS[severity] < LEVELS[level]
|
|
74
|
+
|
|
75
|
+
log_entry = build_log_entry(severity, message, metadata)
|
|
76
|
+
output.puts(JSON.generate(log_entry))
|
|
77
|
+
output.flush
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
# Fallback to plain text if JSON fails
|
|
80
|
+
output.puts("[#{severity}] #{message} | Error: #{e.message}")
|
|
81
|
+
output.flush
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Build structured log entry
|
|
85
|
+
def build_log_entry(severity, message, metadata)
|
|
86
|
+
{
|
|
87
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
88
|
+
level: severity.to_s.upcase,
|
|
89
|
+
message: message,
|
|
90
|
+
pid: Process.pid,
|
|
91
|
+
thread_id: Thread.current.object_id
|
|
92
|
+
}.merge(context)
|
|
93
|
+
.merge(normalize_metadata(metadata))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Normalize metadata keys to strings
|
|
97
|
+
def normalize_metadata(metadata)
|
|
98
|
+
return {} unless metadata.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
metadata.transform_keys(&:to_s)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Normalize log level
|
|
104
|
+
def normalize_level(lvl)
|
|
105
|
+
lvl = lvl.to_sym if lvl.is_a?(String)
|
|
106
|
+
return lvl if LEVELS.key?(lvl)
|
|
107
|
+
|
|
108
|
+
:info
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Logger factory for creating structured loggers
|
|
113
|
+
module LoggerFactory
|
|
114
|
+
# Create a structured logger from configuration
|
|
115
|
+
#
|
|
116
|
+
# @param config [Config] Application configuration
|
|
117
|
+
# @return [StructuredLogger] Configured logger
|
|
118
|
+
def self.create_from_config(config)
|
|
119
|
+
level = config.logger&.level || :info
|
|
120
|
+
|
|
121
|
+
StructuredLogger.new(
|
|
122
|
+
output: $stdout,
|
|
123
|
+
level: level,
|
|
124
|
+
context: {
|
|
125
|
+
app_name: config.app_name,
|
|
126
|
+
env: config.env
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Create a logger for a specific component
|
|
132
|
+
#
|
|
133
|
+
# @param component [String] Component name
|
|
134
|
+
# @param config [Config] Application configuration
|
|
135
|
+
# @return [StructuredLogger] Logger with component context
|
|
136
|
+
def self.for_component(component, config)
|
|
137
|
+
create_from_config(config).with_context(component: component)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|