jetstream_bridge 1.6.0 → 1.8.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.idea/dictionaries/project.xml +1 -0
  3. data/.idea/jetstream_bridge.iml +6 -1
  4. data/.rubocop.yml +102 -0
  5. data/Gemfile.lock +1 -7
  6. data/README.md +76 -32
  7. data/jetstream_bridge.gemspec +9 -10
  8. data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +16 -0
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +24 -0
  10. data/lib/generators/jetstream_bridge/install/install_generator.rb +19 -0
  11. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +44 -0
  12. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +24 -0
  13. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +21 -0
  14. data/lib/jetstream_bridge/consumer/consumer.rb +103 -0
  15. data/lib/jetstream_bridge/{consumer_config.rb → consumer/consumer_config.rb} +3 -3
  16. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +50 -0
  17. data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +51 -0
  18. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +102 -0
  19. data/lib/jetstream_bridge/{message_processor.rb → consumer/message_processor.rb} +1 -1
  20. data/lib/jetstream_bridge/consumer/subscription_manager.rb +91 -0
  21. data/lib/jetstream_bridge/{connection.rb → core/connection.rb} +1 -1
  22. data/lib/jetstream_bridge/models/inbox_event.rb +101 -0
  23. data/lib/jetstream_bridge/models/outbox_event.rb +100 -0
  24. data/lib/jetstream_bridge/publisher/outbox_repository.rb +70 -0
  25. data/lib/jetstream_bridge/{publisher.rb → publisher/publisher.rb} +10 -58
  26. data/lib/jetstream_bridge/railtie.rb +37 -0
  27. data/lib/jetstream_bridge/tasks/install.rake +10 -0
  28. data/lib/jetstream_bridge/{overlap_guard.rb → topology/overlap_guard.rb} +6 -4
  29. data/lib/jetstream_bridge/topology/stream.rb +129 -0
  30. data/lib/jetstream_bridge/{topology.rb → topology/topology.rb} +2 -2
  31. data/lib/jetstream_bridge/version.rb +1 -1
  32. data/lib/jetstream_bridge.rb +35 -23
  33. metadata +49 -50
  34. data/lib/jetstream_bridge/consumer.rb +0 -232
  35. data/lib/jetstream_bridge/dlq.rb +0 -24
  36. data/lib/jetstream_bridge/inbox_event.rb +0 -46
  37. data/lib/jetstream_bridge/outbox_event.rb +0 -60
  38. data/lib/jetstream_bridge/stream.rb +0 -114
  39. /data/lib/jetstream_bridge/{config.rb → core/config.rb} +0 -0
  40. /data/lib/jetstream_bridge/{duration.rb → core/duration.rb} +0 -0
  41. /data/lib/jetstream_bridge/{logging.rb → core/logging.rb} +0 -0
  42. /data/lib/jetstream_bridge/{model_utils.rb → core/model_utils.rb} +0 -0
  43. /data/lib/jetstream_bridge/{subject_matcher.rb → topology/subject_matcher.rb} +0 -0
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/jetstream_bridge/railtie.rb
4
+ module JetstreamBridge
5
+ class Railtie < ::Rails::Railtie
6
+ initializer 'jetstream_bridge.defer_model_tweaks' do
7
+ ActiveSupport.on_load(:active_record) do
8
+ ActiveSupport::Reloader.to_prepare do
9
+ # Skip if not connected (e.g., non-DB rake tasks)
10
+ begin
11
+ next unless ActiveRecord::Base.connected?
12
+ rescue StandardError
13
+ next
14
+ end
15
+
16
+ [JetstreamBridge::OutboxEvent, JetstreamBridge::InboxEvent].each do |klass|
17
+ next unless klass.table_exists?
18
+
19
+ %w[payload headers].each do |attr|
20
+ next unless klass.columns_hash.key?(attr)
21
+
22
+ # Only add serialize if the column is not JSON/JSONB
23
+ type = klass.type_for_attribute(attr)
24
+ klass.serialize attr.to_sym, coder: JSON unless type&.json?
25
+ end
26
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
27
+ # Ignore in tasks/environments without DB
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ rake_tasks do
34
+ load File.expand_path('tasks/install.rake', __dir__)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :jetstream_bridge do
4
+ desc 'Install JetstreamBridge (initializer + migrations)'
5
+ task install: :environment do
6
+ puts '[jetstream_bridge] Generating initializer and migrations...'
7
+ Rails::Generators.invoke('jetstream_bridge:install', [], behavior: :invoke, destination_root: Rails.root.to_s)
8
+ puts '[jetstream_bridge] Done.'
9
+ end
10
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require_relative 'subject_matcher'
5
- require_relative 'logging'
5
+ require_relative '../core/logging'
6
6
 
7
7
  module JetstreamBridge
8
8
  # Checks for overlapping subjects.
@@ -12,6 +12,7 @@ module JetstreamBridge
12
12
  def check!(jts, target_name, new_subjects)
13
13
  conflicts = overlaps(jts, target_name, new_subjects)
14
14
  return if conflicts.empty?
15
+
15
16
  raise conflict_message(target_name, conflicts)
16
17
  end
17
18
 
@@ -22,13 +23,13 @@ module JetstreamBridge
22
23
  streams = list_streams_with_subjects(jts)
23
24
  others = streams.reject { |s| s[:name] == target_name }
24
25
 
25
- others.map do |s|
26
+ others.filter_map do |s|
26
27
  pairs = desired.flat_map do |n|
27
28
  Array(s[:subjects]).map(&:to_s).select { |e| SubjectMatcher.overlap?(n, e) }
28
29
  .map { |e| [n, e] }
29
30
  end
30
31
  { name: s[:name], pairs: pairs } unless pairs.empty?
31
- end.compact
32
+ end
32
33
  end
33
34
 
34
35
  # Returns [allowed, blocked] given desired subjects.
@@ -56,9 +57,10 @@ module JetstreamBridge
56
57
  offset = 0
57
58
  loop do
58
59
  resp = js_api_request(jts, '$JS.API.STREAM.NAMES', { offset: offset })
59
- batch = Array(resp['streams']).map { |h| h['name'] }.compact
60
+ batch = Array(resp['streams']).filter_map { |h| h['name'] }
60
61
  names.concat(batch)
61
62
  break if names.size >= resp['total'].to_i || batch.empty?
63
+
62
64
  offset = names.size
63
65
  end
64
66
  names
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/logging'
4
+ require_relative 'overlap_guard'
5
+ require_relative 'subject_matcher'
6
+
7
+ module JetstreamBridge
8
+ # Ensures a stream exists and adds only subjects that are not already covered.
9
+ class Stream
10
+ class << self
11
+ def ensure!(jts, name, subjects)
12
+ desired = normalize_subjects(subjects)
13
+ raise ArgumentError, 'subjects must not be empty' if desired.empty?
14
+
15
+ attempts = 0
16
+ begin
17
+ info = safe_stream_info(jts, name)
18
+ info ? ensure_update(jts, name, info, desired) : ensure_create(jts, name, desired)
19
+ rescue NATS::JetStream::Error => e
20
+ if overlap_error?(e) && (attempts += 1) <= 1
21
+ Logging.warn("Overlap race while ensuring #{name}; retrying once...", tag: 'JetstreamBridge::Stream')
22
+ sleep(0.05)
23
+ retry
24
+ elsif overlap_error?(e)
25
+ Logging.warn(
26
+ "Overlap persists ensuring #{name}; leaving unchanged. err=#{e.message.inspect}",
27
+ tag: 'JetstreamBridge::Stream')
28
+ nil
29
+ else
30
+ raise
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # ---------- Update existing stream ----------
38
+
39
+ def ensure_update(jts, name, info, desired)
40
+ existing = normalize_subjects(info.config.subjects || [])
41
+ to_add = missing_subjects(existing, desired)
42
+ return log_already_covered(name) if to_add.empty?
43
+
44
+ allowed, blocked = OverlapGuard.partition_allowed(jts, name, to_add)
45
+ return log_all_blocked(name, blocked) if allowed.empty?
46
+
47
+ target = (existing + allowed).uniq
48
+ OverlapGuard.check!(jts, name, target)
49
+ jts.update_stream(name: name, subjects: target)
50
+ log_updated(name, allowed, blocked)
51
+ end
52
+
53
+ # ---------- Create new stream ----------
54
+
55
+ def ensure_create(jts, name, desired)
56
+ allowed, blocked = OverlapGuard.partition_allowed(jts, name, desired)
57
+ return log_not_created(name, blocked) if allowed.empty?
58
+
59
+ jts.add_stream(name: name, subjects: allowed, retention: 'interest', storage: 'file')
60
+ log_created(name, allowed, blocked)
61
+ end
62
+
63
+ # ---------- Helpers ----------
64
+
65
+ def safe_stream_info(jts, name)
66
+ jts.stream_info(name)
67
+ rescue NATS::JetStream::Error => e
68
+ return nil if stream_not_found?(e)
69
+ raise
70
+ end
71
+
72
+ def missing_subjects(existing, desired)
73
+ desired.reject { |d| SubjectMatcher.covered?(existing, d) }
74
+ end
75
+
76
+ def normalize_subjects(list)
77
+ Array(list).flatten.compact.map!(&:to_s).reject(&:empty?).uniq
78
+ end
79
+
80
+ def stream_not_found?(error)
81
+ msg = error.message.to_s
82
+ msg =~ /stream\s+not\s+found/i || msg =~ /\b404\b/
83
+ end
84
+
85
+ def overlap_error?(error)
86
+ msg = error.message.to_s
87
+ msg =~ /subjects?\s+overlap/i || msg =~ /\berr_code=10065\b/ || msg =~ /\bstatus_code=400\b/
88
+ end
89
+
90
+ # ---------- Logging wrappers ----------
91
+
92
+ def log_already_covered(name)
93
+ Logging.info("Stream #{name} exists; subjects already covered.", tag: 'JetstreamBridge::Stream')
94
+ end
95
+
96
+ def log_all_blocked(name, blocked)
97
+ if blocked.any?
98
+ Logging.warn(
99
+ "Stream #{name}: all missing subjects are owned by other streams; leaving unchanged. " \
100
+ "blocked=#{blocked.inspect}",
101
+ tag: 'JetstreamBridge::Stream'
102
+ )
103
+ else
104
+ Logging.info("Stream #{name} exists; nothing to add.", tag: 'JetstreamBridge::Stream')
105
+ end
106
+ end
107
+
108
+ def log_updated(name, added, blocked)
109
+ msg = "Updated stream #{name}; added subjects=#{added.inspect}"
110
+ msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
111
+ Logging.info(msg, tag: 'JetstreamBridge::Stream')
112
+ end
113
+
114
+ def log_not_created(name, blocked)
115
+ Logging.warn(
116
+ "Not creating stream #{name}: all desired subjects are owned by other streams. " \
117
+ "blocked=#{blocked.inspect}",
118
+ tag: 'JetstreamBridge::Stream'
119
+ )
120
+ end
121
+
122
+ def log_created(name, allowed, blocked)
123
+ msg = "Created stream #{name} subjects=#{allowed.inspect}"
124
+ msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
125
+ Logging.info(msg, tag: 'JetstreamBridge::Stream')
126
+ end
127
+ end
128
+ end
129
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../core/config'
4
+ require_relative '../core/logging'
3
5
  require_relative 'stream'
4
- require_relative 'config'
5
- require_relative 'logging'
6
6
 
7
7
  module JetstreamBridge
8
8
  class Topology
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '1.6.0'
7
+ VERSION = '1.8.0'
8
8
  end
@@ -1,48 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'jetstream_bridge/version'
4
- require_relative 'jetstream_bridge/config'
5
- require_relative 'jetstream_bridge/duration'
6
- require_relative 'jetstream_bridge/logging'
7
- require_relative 'jetstream_bridge/connection'
8
- require_relative 'jetstream_bridge/publisher'
9
- require_relative 'jetstream_bridge/consumer'
10
-
11
- # JetstreamBridge
12
- #
13
- # Top-level module that exposes configuration and autoloads optional AR models.
14
- # Use `JetstreamBridge.configure` to set defaults for your environment.
15
- module JetstreamBridge
16
- autoload :OutboxEvent, 'jetstream_bridge/outbox_event'
17
- autoload :InboxEvent, 'jetstream_bridge/inbox_event'
4
+ require_relative 'jetstream_bridge/core/config'
5
+ require_relative 'jetstream_bridge/core/duration'
6
+ require_relative 'jetstream_bridge/core/logging'
7
+ require_relative 'jetstream_bridge/core/connection'
8
+ require_relative 'jetstream_bridge/publisher/publisher'
9
+ require_relative 'jetstream_bridge/consumer/consumer'
10
+
11
+ # If you have a Railtie for tasks/eager-loading
12
+ require_relative 'jetstream_bridge/railtie' if defined?(Rails::Railtie)
13
+
14
+ # Load gem-provided models from lib/
15
+ require_relative 'jetstream_bridge/models/outbox_event'
16
+ require_relative 'jetstream_bridge/models/inbox_event'
17
+
18
18
 
19
+ # JetstreamBridge main module.
20
+ module JetstreamBridge
19
21
  class << self
20
- # Access the global configuration.
21
- # @return [JetstreamBridge::Config]
22
22
  def config
23
23
  @config ||= Config.new
24
24
  end
25
25
 
26
- # Configure via hash and/or block.
27
- # @param overrides [Hash] optional config key/value pairs
28
- # @yieldparam [JetstreamBridge::Config] config
29
- # @return [JetstreamBridge::Config]
30
26
  def configure(overrides = {})
31
27
  cfg = config
32
- overrides.each { |k, v| assign!(cfg, k, v) }
28
+ overrides.each { |k, v| assign!(cfg, k, v) } unless overrides.nil? || overrides.empty?
33
29
  yield(cfg) if block_given?
34
30
  cfg
35
31
  end
36
32
 
37
- # Reset memoized config (useful in tests).
38
33
  def reset!
39
34
  @config = nil
40
35
  end
41
36
 
37
+ def use_outbox?
38
+ config.use_outbox
39
+ end
40
+
41
+ def use_inbox?
42
+ config.use_inbox
43
+ end
44
+
45
+ def use_dlq?
46
+ config.use_dlq
47
+ end
48
+
49
+ def ensure_topology!
50
+ Connection.connect!
51
+ true
52
+ end
53
+
42
54
  private
43
55
 
44
56
  def assign!(cfg, key, val)
45
- setter = "#{key}="
57
+ setter = :"#{key}="
46
58
  raise ArgumentError, "Unknown configuration option: #{key}" unless cfg.respond_to?(setter)
47
59
 
48
60
  cfg.public_send(setter, val)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jetstream_bridge
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Attara
@@ -67,75 +67,61 @@ dependencies:
67
67
  - !ruby/object:Gem::Version
68
68
  version: '6.0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: rake
70
+ name: bundler-audit
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - ">="
74
74
  - !ruby/object:Gem::Version
75
- version: '13.0'
75
+ version: 0.9.1
76
76
  type: :development
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
- version: '13.0'
82
+ version: 0.9.1
83
83
  - !ruby/object:Gem::Dependency
84
- name: rspec
84
+ name: rake
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - ">="
88
88
  - !ruby/object:Gem::Version
89
- version: '3.12'
89
+ version: '13.0'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
- version: '3.12'
97
- - !ruby/object:Gem::Dependency
98
- name: rubocop
99
- requirement: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - "~>"
102
- - !ruby/object:Gem::Version
103
- version: '1.66'
104
- type: :development
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - "~>"
109
- - !ruby/object:Gem::Version
110
- version: '1.66'
96
+ version: '13.0'
111
97
  - !ruby/object:Gem::Dependency
112
- name: rubocop-performance
98
+ name: rspec
113
99
  requirement: !ruby/object:Gem::Requirement
114
100
  requirements:
115
- - - "~>"
101
+ - - ">="
116
102
  - !ruby/object:Gem::Version
117
- version: '1.21'
103
+ version: '3.12'
118
104
  type: :development
119
105
  prerelease: false
120
106
  version_requirements: !ruby/object:Gem::Requirement
121
107
  requirements:
122
- - - "~>"
108
+ - - ">="
123
109
  - !ruby/object:Gem::Version
124
- version: '1.21'
110
+ version: '3.12'
125
111
  - !ruby/object:Gem::Dependency
126
- name: rubocop-rake
112
+ name: rubocop
127
113
  requirement: !ruby/object:Gem::Requirement
128
114
  requirements:
129
115
  - - "~>"
130
116
  - !ruby/object:Gem::Version
131
- version: '0.6'
117
+ version: '1.66'
132
118
  type: :development
133
119
  prerelease: false
134
120
  version_requirements: !ruby/object:Gem::Requirement
135
121
  requirements:
136
122
  - - "~>"
137
123
  - !ruby/object:Gem::Version
138
- version: '0.6'
124
+ version: '1.66'
139
125
  - !ruby/object:Gem::Dependency
140
126
  name: rubocop-packaging
141
127
  requirement: !ruby/object:Gem::Requirement
@@ -151,19 +137,19 @@ dependencies:
151
137
  - !ruby/object:Gem::Version
152
138
  version: '0.5'
153
139
  - !ruby/object:Gem::Dependency
154
- name: bundler-audit
140
+ name: rubocop-performance
155
141
  requirement: !ruby/object:Gem::Requirement
156
142
  requirements:
157
- - - ">="
143
+ - - "~>"
158
144
  - !ruby/object:Gem::Version
159
- version: 0.9.1
145
+ version: '1.21'
160
146
  type: :development
161
147
  prerelease: false
162
148
  version_requirements: !ruby/object:Gem::Requirement
163
149
  requirements:
164
- - - ">="
150
+ - - "~>"
165
151
  - !ruby/object:Gem::Version
166
- version: 0.9.1
152
+ version: '1.21'
167
153
  description: |-
168
154
  Publisher/Consumer utilities for NATS JetStream with environment-scoped subjects,
169
155
  overlap guards, DLQ routing, retries/backoff, and optional Inbox/Outbox patterns.
@@ -182,28 +168,41 @@ files:
182
168
  - ".idea/misc.xml"
183
169
  - ".idea/modules.xml"
184
170
  - ".idea/vcs.xml"
171
+ - ".rubocop.yml"
185
172
  - Gemfile
186
173
  - Gemfile.lock
187
174
  - LICENSE
188
175
  - README.md
189
176
  - jetstream_bridge.gemspec
177
+ - lib/generators/jetstream_bridge/initializer/initializer_generator.rb
178
+ - lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb
179
+ - lib/generators/jetstream_bridge/install/install_generator.rb
180
+ - lib/generators/jetstream_bridge/migrations/migrations_generator.rb
181
+ - lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb
182
+ - lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb
190
183
  - lib/jetstream_bridge.rb
191
- - lib/jetstream_bridge/config.rb
192
- - lib/jetstream_bridge/connection.rb
193
- - lib/jetstream_bridge/consumer.rb
194
- - lib/jetstream_bridge/consumer_config.rb
195
- - lib/jetstream_bridge/dlq.rb
196
- - lib/jetstream_bridge/duration.rb
197
- - lib/jetstream_bridge/inbox_event.rb
198
- - lib/jetstream_bridge/logging.rb
199
- - lib/jetstream_bridge/message_processor.rb
200
- - lib/jetstream_bridge/model_utils.rb
201
- - lib/jetstream_bridge/outbox_event.rb
202
- - lib/jetstream_bridge/overlap_guard.rb
203
- - lib/jetstream_bridge/publisher.rb
204
- - lib/jetstream_bridge/stream.rb
205
- - lib/jetstream_bridge/subject_matcher.rb
206
- - lib/jetstream_bridge/topology.rb
184
+ - lib/jetstream_bridge/consumer/consumer.rb
185
+ - lib/jetstream_bridge/consumer/consumer_config.rb
186
+ - lib/jetstream_bridge/consumer/inbox/inbox_message.rb
187
+ - lib/jetstream_bridge/consumer/inbox/inbox_processor.rb
188
+ - lib/jetstream_bridge/consumer/inbox/inbox_repository.rb
189
+ - lib/jetstream_bridge/consumer/message_processor.rb
190
+ - lib/jetstream_bridge/consumer/subscription_manager.rb
191
+ - lib/jetstream_bridge/core/config.rb
192
+ - lib/jetstream_bridge/core/connection.rb
193
+ - lib/jetstream_bridge/core/duration.rb
194
+ - lib/jetstream_bridge/core/logging.rb
195
+ - lib/jetstream_bridge/core/model_utils.rb
196
+ - lib/jetstream_bridge/models/inbox_event.rb
197
+ - lib/jetstream_bridge/models/outbox_event.rb
198
+ - lib/jetstream_bridge/publisher/outbox_repository.rb
199
+ - lib/jetstream_bridge/publisher/publisher.rb
200
+ - lib/jetstream_bridge/railtie.rb
201
+ - lib/jetstream_bridge/tasks/install.rake
202
+ - lib/jetstream_bridge/topology/overlap_guard.rb
203
+ - lib/jetstream_bridge/topology/stream.rb
204
+ - lib/jetstream_bridge/topology/subject_matcher.rb
205
+ - lib/jetstream_bridge/topology/topology.rb
207
206
  - lib/jetstream_bridge/version.rb
208
207
  homepage: https://github.com/attaradev/jetstream_bridge
209
208
  licenses: