jetstream_bridge 2.9.0 → 3.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +164 -0
  3. data/LICENSE +21 -0
  4. data/README.md +379 -0
  5. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +65 -0
  6. data/lib/generators/jetstream_bridge/health_check/templates/health_controller.rb +38 -0
  7. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +61 -13
  8. data/lib/generators/jetstream_bridge/install/install_generator.rb +4 -2
  9. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +1 -0
  10. data/lib/jetstream_bridge/consumer/consumer.rb +50 -9
  11. data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
  12. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +8 -2
  13. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -61
  14. data/lib/jetstream_bridge/consumer/message_processor.rb +105 -33
  15. data/lib/jetstream_bridge/consumer/subscription_manager.rb +13 -2
  16. data/lib/jetstream_bridge/core/config.rb +37 -1
  17. data/lib/jetstream_bridge/core/connection.rb +80 -3
  18. data/lib/jetstream_bridge/core/connection_factory.rb +102 -0
  19. data/lib/jetstream_bridge/core/debug_helper.rb +107 -0
  20. data/lib/jetstream_bridge/core/duration.rb +8 -1
  21. data/lib/jetstream_bridge/core/logging.rb +20 -7
  22. data/lib/jetstream_bridge/core/model_utils.rb +4 -3
  23. data/lib/jetstream_bridge/core/retry_strategy.rb +135 -0
  24. data/lib/jetstream_bridge/errors.rb +39 -0
  25. data/lib/jetstream_bridge/inbox_event.rb +4 -4
  26. data/lib/jetstream_bridge/models/event_envelope.rb +133 -0
  27. data/lib/jetstream_bridge/models/subject.rb +94 -0
  28. data/lib/jetstream_bridge/outbox_event.rb +3 -1
  29. data/lib/jetstream_bridge/publisher/outbox_repository.rb +47 -28
  30. data/lib/jetstream_bridge/publisher/publisher.rb +12 -35
  31. data/lib/jetstream_bridge/railtie.rb +35 -1
  32. data/lib/jetstream_bridge/tasks/install.rake +99 -0
  33. data/lib/jetstream_bridge/topology/overlap_guard.rb +15 -1
  34. data/lib/jetstream_bridge/topology/stream.rb +16 -8
  35. data/lib/jetstream_bridge/topology/subject_matcher.rb +17 -7
  36. data/lib/jetstream_bridge/topology/topology.rb +1 -1
  37. data/lib/jetstream_bridge/version.rb +1 -1
  38. data/lib/jetstream_bridge.rb +63 -6
  39. metadata +51 -10
  40. data/lib/jetstream_bridge/consumer/backoff_strategy.rb +0 -24
  41. data/lib/jetstream_bridge/consumer/consumer_config.rb +0 -26
  42. data/lib/jetstream_bridge/consumer/message_context.rb +0 -22
@@ -96,17 +96,27 @@ module JetstreamBridge
96
96
  raise ArgumentError, 'subjects must not be empty' if desired.empty?
97
97
 
98
98
  attempts = 0
99
+ max_attempts = 3
100
+ backoffs = [0.05, 0.2, 0.5]
101
+
99
102
  begin
100
103
  info = safe_stream_info(jts, name)
101
104
  info ? ensure_update(jts, name, info, desired) : ensure_create(jts, name, desired)
102
105
  rescue NATS::JetStream::Error => e
103
- if StreamSupport.overlap_error?(e) && (attempts += 1) <= 1
104
- Logging.warn("Overlap race while ensuring #{name}; retrying once...", tag: 'JetstreamBridge::Stream')
105
- sleep(0.05)
106
+ if StreamSupport.overlap_error?(e) && (attempts += 1) <= max_attempts
107
+ backoff = backoffs[attempts - 1] || backoffs.last
108
+ Logging.warn(
109
+ "Overlap race while ensuring #{name}; retry #{attempts}/#{max_attempts} after #{backoff}s...",
110
+ tag: 'JetstreamBridge::Stream'
111
+ )
112
+ sleep(backoff)
106
113
  retry
107
114
  elsif StreamSupport.overlap_error?(e)
108
- Logging.warn("Overlap persists ensuring #{name}; leaving unchanged. err=#{e.message.inspect}",
109
- tag: 'JetstreamBridge::Stream')
115
+ Logging.warn(
116
+ "Overlap persists ensuring #{name} after #{attempts} attempts; leaving unchanged. " \
117
+ "err=#{e.message.inspect}",
118
+ tag: 'JetstreamBridge::Stream'
119
+ )
110
120
  nil
111
121
  else
112
122
  raise
@@ -123,9 +133,7 @@ module JetstreamBridge
123
133
 
124
134
  # Retention is immutable; warn if different and do not include on update.
125
135
  have_ret = info.config.retention.to_s.downcase
126
- if have_ret != RETENTION
127
- StreamSupport.log_retention_mismatch(name, have: have_ret, want: RETENTION)
128
- end
136
+ StreamSupport.log_retention_mismatch(name, have: have_ret, want: RETENTION) if have_ret != RETENTION
129
137
 
130
138
  # Storage can be updated; do it without passing retention.
131
139
  have_storage = info.config.storage.to_s.downcase
@@ -48,20 +48,30 @@ module JetstreamBridge
48
48
  while ai < a_parts.length && bi < b_parts.length
49
49
  at = a_parts[ai]
50
50
  bt = b_parts[bi]
51
- return true if at == '>' || bt == '>'
52
- return false unless at == bt || at == '*' || bt == '*'
51
+ return true if tail?(at, bt)
52
+ return false unless token_match?(at, bt)
53
53
 
54
54
  ai += 1
55
55
  bi += 1
56
56
  end
57
57
 
58
- # If any side still has a '>' remaining, it can absorb the other's remainder
59
- a_tail = a_parts[ai..] || []
60
- b_tail = b_parts[bi..] || []
58
+ tail_overlap?(a_parts[ai..], b_parts[bi..])
59
+ end
60
+
61
+ def tail?(a_token, b_token)
62
+ a_token == '>' || b_token == '>'
63
+ end
64
+
65
+ def token_match?(a_token, b_token)
66
+ a_token == b_token || a_token == '*' || b_token == '*'
67
+ end
68
+
69
+ def tail_overlap?(a_tail, b_tail)
70
+ a_tail ||= []
71
+ b_tail ||= []
61
72
  return true if a_tail.include?('>') || b_tail.include?('>')
62
73
 
63
- # Otherwise they overlap only if both consumed exactly
64
- ai == a_parts.length && bi == b_parts.length
74
+ a_tail.empty? && b_tail.empty?
65
75
  end
66
76
  end
67
77
  end
@@ -14,7 +14,7 @@ module JetstreamBridge
14
14
 
15
15
  Logging.info(
16
16
  "Subjects ready: producer=#{cfg.source_subject}, consumer=#{cfg.destination_subject}. " \
17
- "Counterpart publishes on #{cfg.destination_subject} and subscribes on #{cfg.source_subject}.",
17
+ "Counterpart publishes on #{cfg.destination_subject} and subscribes on #{cfg.source_subject}.",
18
18
  tag: 'JetstreamBridge::Topology'
19
19
  )
20
20
  end
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '2.9.0'
7
+ VERSION = '3.0.0'
8
8
  end
@@ -15,7 +15,6 @@ require_relative 'jetstream_bridge/railtie' if defined?(Rails::Railtie)
15
15
  require_relative 'jetstream_bridge/inbox_event'
16
16
  require_relative 'jetstream_bridge/outbox_event'
17
17
 
18
-
19
18
  # JetstreamBridge main module.
20
19
  module JetstreamBridge
21
20
  class << self
@@ -54,15 +53,73 @@ module JetstreamBridge
54
53
  Connection.jetstream
55
54
  end
56
55
 
57
- # @deprecated Use {ensure_topology!} instead. This method will be removed
58
- # in a future version.
59
- def ensure_topology?
60
- Logging.warn('ensure_topology? is deprecated; use ensure_topology! instead', tag: 'JetstreamBridge')
61
- !!ensure_topology!
56
+ # Health check for monitoring and readiness probes
57
+ #
58
+ # @return [Hash] Health status including NATS connection, stream, and version
59
+ def health_check
60
+ conn_instance = Connection.instance
61
+ connected = conn_instance.connected?
62
+ connected_at = conn_instance.connected_at
63
+
64
+ stream_info = fetch_stream_info if connected
65
+
66
+ {
67
+ healthy: connected && stream_info[:exists],
68
+ nats_connected: connected,
69
+ connected_at: connected_at&.iso8601,
70
+ stream: stream_info,
71
+ config: {
72
+ env: config.env,
73
+ app_name: config.app_name,
74
+ destination_app: config.destination_app,
75
+ use_outbox: config.use_outbox,
76
+ use_inbox: config.use_inbox,
77
+ use_dlq: config.use_dlq
78
+ },
79
+ version: JetstreamBridge::VERSION
80
+ }
81
+ rescue StandardError => e
82
+ {
83
+ healthy: false,
84
+ error: "#{e.class}: #{e.message}"
85
+ }
86
+ end
87
+
88
+ # Check if connected to NATS
89
+ #
90
+ # @return [Boolean] true if connected and healthy
91
+ def connected?
92
+ Connection.instance.connected?
93
+ rescue StandardError
94
+ false
95
+ end
96
+
97
+ # Get stream information for the configured stream
98
+ #
99
+ # @return [Hash] Stream information including subjects and message count
100
+ def stream_info
101
+ fetch_stream_info
62
102
  end
63
103
 
64
104
  private
65
105
 
106
+ def fetch_stream_info
107
+ jts = Connection.jetstream
108
+ info = jts.stream_info(config.stream_name)
109
+ {
110
+ exists: true,
111
+ name: config.stream_name,
112
+ subjects: info.config.subjects,
113
+ messages: info.state.messages
114
+ }
115
+ rescue StandardError => e
116
+ {
117
+ exists: false,
118
+ name: config.stream_name,
119
+ error: "#{e.class}: #{e.message}"
120
+ }
121
+ end
122
+
66
123
  def assign!(cfg, key, val)
67
124
  setter = :"#{key}="
68
125
  raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jetstream_bridge
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.9.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Attara
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-25 00:00:00.000000000 Z
11
+ date: 2025-11-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -17,6 +17,9 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '6.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '8.0'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -24,6 +27,9 @@ dependencies:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: '6.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: activesupport
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -31,6 +37,9 @@ dependencies:
31
37
  - - ">="
32
38
  - !ruby/object:Gem::Version
33
39
  version: '6.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '8.0'
34
43
  type: :runtime
35
44
  prerelease: false
36
45
  version_requirements: !ruby/object:Gem::Requirement
@@ -38,6 +47,9 @@ dependencies:
38
47
  - - ">="
39
48
  - !ruby/object:Gem::Version
40
49
  version: '6.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '8.0'
41
53
  - !ruby/object:Gem::Dependency
42
54
  name: nats-pure
43
55
  requirement: !ruby/object:Gem::Requirement
@@ -59,6 +71,9 @@ dependencies:
59
71
  - - ">="
60
72
  - !ruby/object:Gem::Version
61
73
  version: '3.16'
74
+ - - "<"
75
+ - !ruby/object:Gem::Version
76
+ version: '4.0'
62
77
  type: :runtime
63
78
  prerelease: false
64
79
  version_requirements: !ruby/object:Gem::Requirement
@@ -66,16 +81,39 @@ dependencies:
66
81
  - - ">="
67
82
  - !ruby/object:Gem::Version
68
83
  version: '3.16'
84
+ - - "<"
85
+ - !ruby/object:Gem::Version
86
+ version: '4.0'
87
+ - !ruby/object:Gem::Dependency
88
+ name: mutex_m
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ type: :runtime
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
69
101
  description: |-
70
102
  Publisher/Consumer utilities for NATS JetStream with environment-scoped subjects,
71
103
  overlap guards, DLQ routing, retries/backoff, and optional Inbox/Outbox patterns.
72
- Includes topology setup helpers for production-safe operation.
104
+ Includes health checks, auto-reconnection, graceful shutdown, and topology setup
105
+ helpers for production-safe operation.
73
106
  email:
74
107
  - mpyebattara@gmail.com
75
108
  executables: []
76
109
  extensions: []
77
110
  extra_rdoc_files: []
78
111
  files:
112
+ - CHANGELOG.md
113
+ - LICENSE
114
+ - README.md
115
+ - lib/generators/jetstream_bridge/health_check/health_check_generator.rb
116
+ - lib/generators/jetstream_bridge/health_check/templates/health_controller.rb
79
117
  - lib/generators/jetstream_bridge/initializer/initializer_generator.rb
80
118
  - lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb
81
119
  - lib/generators/jetstream_bridge/install/install_generator.rb
@@ -83,23 +121,26 @@ files:
83
121
  - lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb
84
122
  - lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb
85
123
  - lib/jetstream_bridge.rb
86
- - lib/jetstream_bridge/consumer/backoff_strategy.rb
87
124
  - lib/jetstream_bridge/consumer/consumer.rb
88
- - lib/jetstream_bridge/consumer/consumer_config.rb
89
125
  - lib/jetstream_bridge/consumer/dlq_publisher.rb
90
126
  - lib/jetstream_bridge/consumer/inbox/inbox_message.rb
91
127
  - lib/jetstream_bridge/consumer/inbox/inbox_processor.rb
92
128
  - lib/jetstream_bridge/consumer/inbox/inbox_repository.rb
93
- - lib/jetstream_bridge/consumer/message_context.rb
94
129
  - lib/jetstream_bridge/consumer/message_processor.rb
95
130
  - lib/jetstream_bridge/consumer/subscription_manager.rb
96
131
  - lib/jetstream_bridge/core/config.rb
97
132
  - lib/jetstream_bridge/core/connection.rb
133
+ - lib/jetstream_bridge/core/connection_factory.rb
134
+ - lib/jetstream_bridge/core/debug_helper.rb
98
135
  - lib/jetstream_bridge/core/duration.rb
99
136
  - lib/jetstream_bridge/core/logging.rb
100
137
  - lib/jetstream_bridge/core/model_codec_setup.rb
101
138
  - lib/jetstream_bridge/core/model_utils.rb
139
+ - lib/jetstream_bridge/core/retry_strategy.rb
140
+ - lib/jetstream_bridge/errors.rb
102
141
  - lib/jetstream_bridge/inbox_event.rb
142
+ - lib/jetstream_bridge/models/event_envelope.rb
143
+ - lib/jetstream_bridge/models/subject.rb
103
144
  - lib/jetstream_bridge/outbox_event.rb
104
145
  - lib/jetstream_bridge/publisher/outbox_repository.rb
105
146
  - lib/jetstream_bridge/publisher/publisher.rb
@@ -119,8 +160,8 @@ metadata:
119
160
  changelog_uri: https://github.com/attaradev/jetstream_bridge/blob/main/CHANGELOG.md
120
161
  documentation_uri: https://github.com/attaradev/jetstream_bridge#readme
121
162
  bug_tracker_uri: https://github.com/attaradev/jetstream_bridge/issues
122
- github_repo: ssh://github.com/attaradev/jetstream_bridge
123
163
  rubygems_mfa_required: 'true'
164
+ allowed_push_host: https://rubygems.org
124
165
  post_install_message:
125
166
  rdoc_options: []
126
167
  require_paths:
@@ -129,15 +170,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
129
170
  requirements:
130
171
  - - ">="
131
172
  - !ruby/object:Gem::Version
132
- version: 2.7.0
173
+ version: 3.2.0
133
174
  required_rubygems_version: !ruby/object:Gem::Requirement
134
175
  requirements:
135
176
  - - ">="
136
177
  - !ruby/object:Gem::Version
137
- version: 3.3.0
178
+ version: '0'
138
179
  requirements: []
139
180
  rubygems_version: 3.4.19
140
181
  signing_key:
141
182
  specification_version: 4
142
- summary: Reliable realtime bridge over NATS JetStream for Rails/Ruby apps
183
+ summary: Production-safe realtime data bridge using NATS JetStream
143
184
  test_files: []
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JetstreamBridge
4
- class BackoffStrategy
5
- TRANSIENT_ERRORS = [Timeout::Error, IOError].freeze
6
- MAX_EXPONENT = 6
7
- MAX_DELAY = 60
8
- MIN_DELAY = 1
9
-
10
- # Returns a bounded delay in seconds
11
- def delay(deliveries, error)
12
- base = transient?(error) ? 0.5 : 2.0
13
- power = [deliveries - 1, MAX_EXPONENT].min
14
- raw = (base * (2**power)).to_i
15
- raw.clamp(MIN_DELAY, MAX_DELAY)
16
- end
17
-
18
- private
19
-
20
- def transient?(error)
21
- TRANSIENT_ERRORS.any? { |k| error.is_a?(k) }
22
- end
23
- end
24
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../core/duration'
4
- require_relative '../core/config'
5
- require_relative '../core/logging'
6
-
7
- module JetstreamBridge
8
- # Consumer configuration helpers.
9
- module ConsumerConfig
10
- module_function
11
-
12
- # Complete consumer config (pre-provisioned durable, pull mode).
13
- def consumer_config(durable, filter_subject)
14
- {
15
- durable_name: durable,
16
- filter_subject: filter_subject,
17
- ack_policy: 'explicit',
18
- deliver_policy: 'all',
19
- max_deliver: JetstreamBridge.config.max_deliver,
20
- ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
21
- backoff: Array(JetstreamBridge.config.backoff)
22
- .map { |d| Duration.to_millis(d) }
23
- }
24
- end
25
- end
26
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'securerandom'
4
-
5
- module JetstreamBridge
6
- # Immutable per-message metadata
7
- MessageContext = Struct.new(
8
- :event_id, :deliveries, :subject, :seq, :consumer, :stream,
9
- keyword_init: true
10
- ) do
11
- def self.build(msg)
12
- new(
13
- event_id: msg.header&.[]('nats-msg-id') || SecureRandom.uuid,
14
- deliveries: msg.metadata&.num_delivered.to_i,
15
- subject: msg.subject,
16
- seq: msg.metadata&.sequence,
17
- consumer: msg.metadata&.consumer,
18
- stream: msg.metadata&.stream
19
- )
20
- end
21
- end
22
- end