jetstream_bridge 4.4.0 → 4.5.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -337
  3. data/README.md +1 -5
  4. data/docs/GETTING_STARTED.md +11 -7
  5. data/docs/PRODUCTION.md +51 -11
  6. data/docs/TESTING.md +24 -35
  7. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +1 -1
  8. data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +1 -1
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +0 -4
  10. data/lib/generators/jetstream_bridge/install/install_generator.rb +5 -5
  11. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +2 -2
  12. data/lib/jetstream_bridge/consumer/consumer.rb +34 -96
  13. data/lib/jetstream_bridge/consumer/health_monitor.rb +107 -0
  14. data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
  15. data/lib/jetstream_bridge/consumer/subscription_manager.rb +51 -34
  16. data/lib/jetstream_bridge/core/config.rb +153 -46
  17. data/lib/jetstream_bridge/core/connection_manager.rb +513 -0
  18. data/lib/jetstream_bridge/core/debug_helper.rb +9 -3
  19. data/lib/jetstream_bridge/core/health_checker.rb +184 -0
  20. data/lib/jetstream_bridge/core.rb +0 -2
  21. data/lib/jetstream_bridge/facade.rb +212 -0
  22. data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +110 -0
  23. data/lib/jetstream_bridge/publisher/publisher.rb +87 -117
  24. data/lib/jetstream_bridge/rails/integration.rb +8 -5
  25. data/lib/jetstream_bridge/rails/railtie.rb +4 -3
  26. data/lib/jetstream_bridge/tasks/install.rake +0 -1
  27. data/lib/jetstream_bridge/topology/topology.rb +6 -1
  28. data/lib/jetstream_bridge/version.rb +1 -1
  29. data/lib/jetstream_bridge.rb +206 -297
  30. metadata +7 -5
  31. data/lib/jetstream_bridge/core/bridge_helpers.rb +0 -109
  32. data/lib/jetstream_bridge/core/connection.rb +0 -464
  33. data/lib/jetstream_bridge/core/connection_factory.rb +0 -100
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+
5
+ module JetstreamBridge
6
+ # Health checking service for JetStream Bridge
7
+ #
8
+ # Responsible for:
9
+ # - Connection health monitoring
10
+ # - Stream information gathering
11
+ # - Performance metrics collection
12
+ # - Rate limiting health check requests
13
+ class HealthChecker
14
+ # Rate limit window for uncached health checks in seconds
15
+ RATE_LIMIT_WINDOW = 5
16
+
17
+ def initialize(connection_manager, config)
18
+ @connection_manager = connection_manager
19
+ @config = config
20
+ @rate_limiter_mutex = Mutex.new
21
+ @last_uncached_check = nil
22
+ end
23
+
24
+ # Perform comprehensive health check
25
+ #
26
+ # @param skip_cache [Boolean] Force fresh health check (rate limited)
27
+ # @return [Hash] Health status including connection, stream, performance, config, and version
28
+ def check(skip_cache: false)
29
+ enforce_rate_limit! if skip_cache
30
+
31
+ start_time = Time.now
32
+
33
+ connection_health = fetch_connection_health(skip_cache)
34
+ stream_info = fetch_stream_info if should_fetch_stream_info?(connection_health)
35
+ rtt_ms = measure_nats_rtt if should_measure_rtt?(connection_health)
36
+
37
+ health_check_duration_ms = ((Time.now - start_time) * 1000).round(2)
38
+
39
+ {
40
+ healthy: overall_healthy?(connection_health, stream_info),
41
+ connection: connection_health,
42
+ stream: stream_info || default_stream_info,
43
+ performance: {
44
+ nats_rtt_ms: rtt_ms,
45
+ health_check_duration_ms: health_check_duration_ms
46
+ },
47
+ config: config_summary,
48
+ version: JetstreamBridge::VERSION
49
+ }
50
+ rescue StandardError => e
51
+ {
52
+ healthy: false,
53
+ connection: {
54
+ state: :failed,
55
+ connected: false
56
+ },
57
+ error: "#{e.class}: #{e.message}"
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def enforce_rate_limit!
64
+ @rate_limiter_mutex.synchronize do
65
+ now = Time.now
66
+ if @last_uncached_check
67
+ time_since = now - @last_uncached_check
68
+ if time_since < RATE_LIMIT_WINDOW
69
+ raise HealthCheckFailedError,
70
+ "Health check rate limit exceeded. Please wait #{(RATE_LIMIT_WINDOW - time_since).ceil} second(s)"
71
+ end
72
+ end
73
+ @last_uncached_check = now
74
+ end
75
+ end
76
+
77
+ def fetch_connection_health(skip_cache)
78
+ if @connection_manager
79
+ @connection_manager.health_check(skip_cache: skip_cache)
80
+ else
81
+ {
82
+ connected: false,
83
+ state: :disconnected,
84
+ connected_at: nil,
85
+ last_error: 'Not connected',
86
+ last_error_at: nil
87
+ }
88
+ end
89
+ end
90
+
91
+ def should_fetch_stream_info?(connection_health)
92
+ connection_health[:connected] && !@config.disable_js_api
93
+ end
94
+
95
+ def should_measure_rtt?(connection_health)
96
+ connection_health[:connected] && !@config.disable_js_api
97
+ end
98
+
99
+ def overall_healthy?(connection_health, stream_info)
100
+ return false unless connection_health[:connected]
101
+ return true if @config.disable_js_api
102
+
103
+ stream_info&.fetch(:exists, false) || false
104
+ end
105
+
106
+ def default_stream_info
107
+ { exists: nil, name: @config.stream_name }
108
+ end
109
+
110
+ def config_summary
111
+ {
112
+ app_name: @config.app_name,
113
+ destination_app: @config.destination_app,
114
+ use_outbox: @config.use_outbox,
115
+ use_inbox: @config.use_inbox,
116
+ use_dlq: @config.use_dlq,
117
+ disable_js_api: @config.disable_js_api
118
+ }
119
+ end
120
+
121
+ def fetch_stream_info
122
+ return nil unless @connection_manager
123
+
124
+ jts = @connection_manager.jetstream
125
+ return nil unless jts
126
+
127
+ info = jts.stream_info(@config.stream_name)
128
+
129
+ # Handle both object-style and hash-style access for compatibility
130
+ config_data = info.config
131
+ state_data = info.state
132
+ subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
133
+ messages = state_data.respond_to?(:messages) ? state_data.messages : state_data[:messages]
134
+
135
+ {
136
+ exists: true,
137
+ name: @config.stream_name,
138
+ subjects: subjects,
139
+ messages: messages
140
+ }
141
+ rescue StandardError => e
142
+ {
143
+ exists: false,
144
+ name: @config.stream_name,
145
+ error: "#{e.class}: #{e.message}"
146
+ }
147
+ end
148
+
149
+ def measure_nats_rtt
150
+ return nil unless @connection_manager
151
+
152
+ nc = @connection_manager.nats_client
153
+ return nil unless nc
154
+
155
+ # Prefer native RTT API when available
156
+ if nc.respond_to?(:rtt)
157
+ rtt_value = normalize_ms(nc.rtt)
158
+ return rtt_value if rtt_value
159
+ end
160
+
161
+ # Fallback: measure ping/pong via flush
162
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
163
+ nc.flush(1)
164
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
165
+ normalize_ms(duration)
166
+ rescue StandardError => e
167
+ Logging.warn(
168
+ "Failed to measure NATS RTT: #{e.class} #{e.message}",
169
+ tag: 'JetstreamBridge::HealthChecker'
170
+ )
171
+ nil
172
+ end
173
+
174
+ def normalize_ms(value)
175
+ return nil if value.nil?
176
+ return nil unless value.respond_to?(:to_f)
177
+
178
+ numeric = value.to_f
179
+ # Heuristic: sub-1 values are likely seconds; convert them to ms
180
+ ms = numeric < 1 ? numeric * 1000 : numeric
181
+ ms.round(2)
182
+ end
183
+ end
184
+ end
@@ -4,5 +4,3 @@
4
4
  require_relative 'core/config'
5
5
  require_relative 'core/duration'
6
6
  require_relative 'core/logging'
7
- require_relative 'core/connection'
8
- require_relative 'core/bridge_helpers'
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'core/config'
4
+ require_relative 'core/connection_manager'
5
+ require_relative 'core/health_checker'
6
+ require_relative 'core/logging'
7
+ require_relative 'publisher/publisher'
8
+ require_relative 'consumer/consumer'
9
+ require_relative 'topology/topology'
10
+
11
+ module JetstreamBridge
12
+ # Facade that coordinates all subsystems
13
+ #
14
+ # Responsible for:
15
+ # - Managing configuration
16
+ # - Managing connection lifecycle
17
+ # - Creating publishers and consumers
18
+ # - Delegating health checks
19
+ class Facade
20
+ attr_reader :config
21
+
22
+ def initialize
23
+ @config = Config.new
24
+ @connection_manager = nil
25
+ @publisher = nil
26
+ @health_checker = nil
27
+ end
28
+
29
+ # Configure JetStream Bridge
30
+ #
31
+ # @yield [Config] Configuration object
32
+ # @return [Config] The configured instance
33
+ def configure
34
+ yield(@config) if block_given?
35
+ @config
36
+ end
37
+
38
+ # Connect to NATS and ensure topology
39
+ #
40
+ # @return [void]
41
+ def connect!
42
+ ensure_connection_manager!
43
+ @connection_manager.connect!
44
+ Logging.info('JetStream Bridge connected successfully', tag: 'JetstreamBridge')
45
+ end
46
+
47
+ # Disconnect from NATS
48
+ #
49
+ # @return [void]
50
+ def disconnect!
51
+ return unless @connection_manager
52
+
53
+ @connection_manager.disconnect!
54
+ Logging.info('JetStream Bridge disconnected', tag: 'JetstreamBridge')
55
+ end
56
+
57
+ # Reconnect to NATS
58
+ #
59
+ # @return [void]
60
+ def reconnect!
61
+ Logging.info('Reconnecting to NATS...', tag: 'JetstreamBridge')
62
+ disconnect!
63
+ connect!
64
+ end
65
+
66
+ # Publish an event
67
+ #
68
+ # @param event_type [String] Event type
69
+ # @param payload [Hash] Event payload
70
+ # @param options [Hash] Additional options
71
+ # @return [Models::PublishResult] Result object
72
+ def publish(event_type:, payload:, **)
73
+ connect_if_needed!
74
+ publisher.publish(event_type: event_type, payload: payload, **)
75
+ end
76
+
77
+ # Publish a complete event envelope (advanced)
78
+ #
79
+ # @param envelope [Hash] Complete event envelope
80
+ # @param subject [String, nil] Optional subject override
81
+ # @return [Models::PublishResult] Result object
82
+ def publish_envelope(envelope, subject: nil)
83
+ connect_if_needed!
84
+ publisher.publish_envelope(envelope, subject: subject)
85
+ end
86
+
87
+ # Subscribe to events
88
+ #
89
+ # @param handler [Proc, #call, nil] Message handler
90
+ # @param options [Hash] Consumer options
91
+ # @yield [event] Optional block as handler
92
+ # @return [Consumer] Consumer instance
93
+ def subscribe(handler = nil, **, &block)
94
+ connect_if_needed!
95
+ create_consumer(handler || block, **)
96
+ end
97
+
98
+ # Check if connected to NATS
99
+ #
100
+ # @param skip_cache [Boolean] Force fresh check
101
+ # @return [Boolean] true if connected and healthy
102
+ def connected?(skip_cache: false)
103
+ return false unless @connection_manager
104
+
105
+ @connection_manager.connected?(skip_cache: skip_cache)
106
+ rescue StandardError
107
+ false
108
+ end
109
+
110
+ # Get comprehensive health status (unified method)
111
+ #
112
+ # Provides complete system health information including connection state,
113
+ # stream info, performance metrics, and configuration.
114
+ #
115
+ # Rate limited to prevent abuse (max 1 uncached check per 5 seconds).
116
+ #
117
+ # @param skip_cache [Boolean] Force fresh health check (rate limited)
118
+ # @return [Hash] Health status including NATS connection, stream, and version
119
+ #
120
+ # @example
121
+ # health = facade.health
122
+ # puts "Healthy: #{health[:healthy]}"
123
+ # puts "Connected: #{health[:connection][:connected]}"
124
+ # puts "Stream exists: #{health[:stream][:exists]}"
125
+ # puts "RTT: #{health[:performance][:nats_rtt_ms]}ms"
126
+ def health(skip_cache: false)
127
+ health_checker.check(skip_cache: skip_cache)
128
+ end
129
+
130
+ # Backward compatibility: Alias for health method
131
+ #
132
+ # @deprecated Use {#health} instead
133
+ # @param skip_cache [Boolean] Force fresh health check
134
+ # @return [Hash] Health status
135
+ def health_check(skip_cache: false)
136
+ health(skip_cache: skip_cache)
137
+ end
138
+
139
+ # Check if system is healthy (convenience method)
140
+ #
141
+ # @return [Boolean] true if connected and stream exists (or JS API disabled)
142
+ #
143
+ # @example
144
+ # if facade.healthy?
145
+ # puts "System is healthy and ready"
146
+ # end
147
+ def healthy?
148
+ health[:healthy]
149
+ rescue StandardError
150
+ false
151
+ end
152
+
153
+ # Get stream information
154
+ #
155
+ # @return [Hash] Stream information
156
+ def stream_info
157
+ health_checker.send(:fetch_stream_info)
158
+ end
159
+
160
+ private
161
+
162
+ def ensure_connection_manager!
163
+ return if @connection_manager
164
+
165
+ @config.validate!
166
+ @connection_manager = ConnectionManager.new(@config)
167
+ end
168
+
169
+ def connect_if_needed!
170
+ return if @connection_manager&.connected?
171
+
172
+ connect!
173
+ end
174
+
175
+ # Get or create publisher instance (cached)
176
+ def publisher
177
+ @publisher ||= begin
178
+ ensure_connection_manager!
179
+ Publisher.new(
180
+ connection: @connection_manager.jetstream,
181
+ config: @config
182
+ )
183
+ end
184
+ end
185
+
186
+ # Create a new consumer instance (not cached - each subscription is independent)
187
+ def create_consumer(handler, durable_name: nil, batch_size: nil)
188
+ ensure_connection_manager!
189
+ Consumer.new(
190
+ handler,
191
+ connection: @connection_manager.jetstream,
192
+ config: @config,
193
+ durable_name: durable_name,
194
+ batch_size: batch_size
195
+ )
196
+ end
197
+
198
+ # Get or create health checker instance (cached)
199
+ def health_checker
200
+ @health_checker ||= begin
201
+ # Try to ensure connection manager, but don't fail if config is invalid
202
+ # HealthChecker will handle the nil connection_manager gracefully
203
+ begin
204
+ ensure_connection_manager!
205
+ rescue ConfigurationError
206
+ # Config not valid yet, health checker will report as unhealthy
207
+ end
208
+ HealthChecker.new(@connection_manager, @config)
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'time'
5
+
6
+ module JetstreamBridge
7
+ # Builds event envelopes for publishing
8
+ #
9
+ # Single responsibility: Convert event parameters into a valid envelope hash.
10
+ # All envelope construction logic is centralized here.
11
+ class EventEnvelopeBuilder
12
+ # Build an event envelope from parameters
13
+ #
14
+ # @param event_type [String] Event type (e.g., 'created', 'user.created')
15
+ # @param payload [Hash] Event payload data
16
+ # @param resource_type [String, nil] Resource type (inferred from event_type if nil)
17
+ # @param options [Hash] Optional envelope fields
18
+ # @option options [String] :event_id Custom event ID (auto-generated if not provided)
19
+ # @option options [String] :producer Producer name (defaults to app_name from config)
20
+ # @option options [String, Time] :occurred_at Event timestamp (defaults to current time)
21
+ # @option options [String] :trace_id Distributed trace ID (auto-generated if not provided)
22
+ # @option options [String] :resource_id Resource ID (extracted from payload if not provided)
23
+ # @option options [Integer] :schema_version Schema version (defaults to 1)
24
+ #
25
+ # @return [Hash] Complete event envelope
26
+ # @raise [ArgumentError] If required parameters are missing
27
+ def self.build(event_type:, payload:, resource_type: nil, **options)
28
+ new(event_type, payload, resource_type, options).build
29
+ end
30
+
31
+ def initialize(event_type, payload, resource_type, options)
32
+ @event_type = event_type.to_s
33
+ @payload = payload
34
+ @resource_type = resource_type
35
+ @options = options
36
+
37
+ validate_required!
38
+ end
39
+
40
+ def build
41
+ {
42
+ 'event_id' => event_id,
43
+ 'schema_version' => schema_version,
44
+ 'event_type' => @event_type,
45
+ 'producer' => producer,
46
+ 'resource_type' => resource_type,
47
+ 'resource_id' => resource_id,
48
+ 'occurred_at' => occurred_at,
49
+ 'trace_id' => trace_id,
50
+ 'payload' => @payload
51
+ }
52
+ end
53
+
54
+ private
55
+
56
+ def validate_required!
57
+ raise ArgumentError, 'event_type is required' if @event_type.empty?
58
+ raise ArgumentError, 'payload is required' if @payload.nil?
59
+ end
60
+
61
+ def event_id
62
+ @options[:event_id] || SecureRandom.uuid
63
+ end
64
+
65
+ def schema_version
66
+ @options[:schema_version] || 1
67
+ end
68
+
69
+ def producer
70
+ @options[:producer] || JetstreamBridge.config.app_name
71
+ end
72
+
73
+ def resource_type
74
+ @resource_type || infer_resource_type || 'event'
75
+ end
76
+
77
+ def infer_resource_type
78
+ # Extract from "user.created" -> "user"
79
+ @event_type.split('.').first if @event_type.include?('.')
80
+ end
81
+
82
+ def resource_id
83
+ @options[:resource_id] || extract_resource_id_from_payload
84
+ end
85
+
86
+ def extract_resource_id_from_payload
87
+ return '' unless @payload.respond_to?(:[])
88
+
89
+ payload_normalized = @payload.transform_keys(&:to_s) if @payload.respond_to?(:transform_keys)
90
+ payload_normalized ||= @payload
91
+
92
+ (payload_normalized['id'] || payload_normalized[:id] ||
93
+ payload_normalized['resource_id'] || payload_normalized[:resource_id]).to_s
94
+ end
95
+
96
+ def occurred_at
97
+ value = @options[:occurred_at]
98
+ return Time.now.utc.iso8601 if value.nil?
99
+ return value.iso8601 if value.is_a?(Time)
100
+
101
+ Time.parse(value.to_s).iso8601
102
+ rescue ArgumentError
103
+ Time.now.utc.iso8601
104
+ end
105
+
106
+ def trace_id
107
+ @options[:trace_id] || SecureRandom.hex(8)
108
+ end
109
+ end
110
+ end