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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/exe/nats_pubsub +44 -0
  3. data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
  4. data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
  5. data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
  6. data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
  7. data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
  8. data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
  9. data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
  10. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
  11. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
  12. data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
  13. data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
  14. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
  15. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
  16. data/lib/nats_pubsub/active_record/publishable.rb +192 -0
  17. data/lib/nats_pubsub/cli.rb +105 -0
  18. data/lib/nats_pubsub/core/base_repository.rb +73 -0
  19. data/lib/nats_pubsub/core/config.rb +152 -0
  20. data/lib/nats_pubsub/core/config_presets.rb +139 -0
  21. data/lib/nats_pubsub/core/connection.rb +103 -0
  22. data/lib/nats_pubsub/core/constants.rb +190 -0
  23. data/lib/nats_pubsub/core/duration.rb +113 -0
  24. data/lib/nats_pubsub/core/error_action.rb +288 -0
  25. data/lib/nats_pubsub/core/event.rb +275 -0
  26. data/lib/nats_pubsub/core/health_check.rb +470 -0
  27. data/lib/nats_pubsub/core/logging.rb +72 -0
  28. data/lib/nats_pubsub/core/message_context.rb +193 -0
  29. data/lib/nats_pubsub/core/presets.rb +222 -0
  30. data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
  31. data/lib/nats_pubsub/core/structured_logger.rb +141 -0
  32. data/lib/nats_pubsub/core/subject.rb +185 -0
  33. data/lib/nats_pubsub/instrumentation.rb +327 -0
  34. data/lib/nats_pubsub/middleware/active_record.rb +18 -0
  35. data/lib/nats_pubsub/middleware/chain.rb +92 -0
  36. data/lib/nats_pubsub/middleware/logging.rb +48 -0
  37. data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
  38. data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
  39. data/lib/nats_pubsub/models/event_model.rb +73 -0
  40. data/lib/nats_pubsub/models/inbox_event.rb +109 -0
  41. data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
  42. data/lib/nats_pubsub/models/model_utils.rb +57 -0
  43. data/lib/nats_pubsub/models/outbox_event.rb +113 -0
  44. data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
  45. data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
  46. data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
  47. data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
  48. data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
  49. data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
  50. data/lib/nats_pubsub/publisher/publisher.rb +156 -0
  51. data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
  52. data/lib/nats_pubsub/railtie.rb +52 -0
  53. data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
  54. data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
  55. data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
  56. data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
  57. data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
  58. data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
  59. data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
  60. data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
  61. data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
  62. data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
  63. data/lib/nats_pubsub/subscribers/pool.rb +166 -0
  64. data/lib/nats_pubsub/subscribers/registry.rb +114 -0
  65. data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
  66. data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
  67. data/lib/nats_pubsub/subscribers/worker.rb +152 -0
  68. data/lib/nats_pubsub/tasks/install.rake +10 -0
  69. data/lib/nats_pubsub/testing/helpers.rb +199 -0
  70. data/lib/nats_pubsub/testing/matchers.rb +208 -0
  71. data/lib/nats_pubsub/testing/test_harness.rb +250 -0
  72. data/lib/nats_pubsub/testing.rb +157 -0
  73. data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
  74. data/lib/nats_pubsub/topology/stream.rb +102 -0
  75. data/lib/nats_pubsub/topology/stream_support.rb +170 -0
  76. data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
  77. data/lib/nats_pubsub/topology/topology.rb +24 -0
  78. data/lib/nats_pubsub/version.rb +8 -0
  79. data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
  80. data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
  81. data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
  82. data/lib/nats_pubsub/web/views/layout.erb +68 -0
  83. data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
  84. data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
  85. data/lib/nats_pubsub/web.rb +181 -0
  86. data/lib/nats_pubsub.rb +290 -0
  87. metadata +225 -0
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ # Include this module in your subscriber classes to handle NATS JetStream messages.
5
+ # Uses topic-based subscriptions.
6
+ #
7
+ # Example:
8
+ # class NotificationSubscriber < NatsPubsub::Subscriber
9
+ # subscribe_to 'notifications.email'
10
+ # # or with wildcards
11
+ # subscribe_to 'users.user.*'
12
+ # subscribe_to_wildcard 'notifications'
13
+ #
14
+ # jetstream_options retry: 3, ack_wait: 60
15
+ #
16
+ # def handle(message, context)
17
+ # # Handle the message
18
+ # # context is a MessageContext object with event_id, trace_id, deliveries, etc.
19
+ # end
20
+ # end
21
+ module Subscriber
22
+ def self.included(base)
23
+ base.extend(ClassMethods)
24
+ base.include(InstanceMethods)
25
+ end
26
+
27
+ module ClassMethods
28
+ # ===== Topic-Based Subscriptions =====
29
+
30
+ # Subscribe to one or more topics
31
+ # @param topics [Array<String>] Topic names to subscribe to
32
+ # @param options [Hash] Additional subscription options
33
+ #
34
+ # Examples:
35
+ # subscribe_to 'notifications', 'audit'
36
+ # subscribe_to 'analytics', ack_wait: 60
37
+ # subscribe_to 'users.user.*' # With wildcard
38
+ #
39
+ # Supports wildcards for matching:
40
+ # subscribe_to 'users.user.*' # Match one level
41
+ # subscribe_to_wildcard 'users' # Match all subtopics with >
42
+ def subscribe_to(*topics, **options)
43
+ @topic_subscriptions ||= []
44
+ topics.each do |topic|
45
+ pattern = build_topic_pattern(topic)
46
+ @topic_subscriptions << {
47
+ pattern: pattern,
48
+ topic: topic.to_s,
49
+ type: :topic,
50
+ options: options
51
+ }
52
+ end
53
+ end
54
+
55
+ # Subscribe to all subtopics within a topic using wildcard (>)
56
+ # @param topic [String] Topic name
57
+ # @param options [Hash] Additional subscription options
58
+ #
59
+ # Example:
60
+ # subscribe_to_wildcard 'notifications' # Subscribes to notifications.>
61
+ def subscribe_to_wildcard(topic, **options)
62
+ @topic_subscriptions ||= []
63
+ pattern = build_topic_wildcard_pattern(topic)
64
+ @topic_subscriptions << {
65
+ pattern: pattern,
66
+ topic: topic.to_s,
67
+ type: :topic_wildcard,
68
+ options: options
69
+ }
70
+ end
71
+
72
+ # Get all topic subscriptions
73
+ # @return [Array<Hash>] Array of subscription hashes
74
+ def topic_subscriptions
75
+ @topic_subscriptions || []
76
+ end
77
+
78
+ # Get all subscriptions
79
+ # @return [Array<Hash>] Array of all subscription hashes
80
+ def all_subscriptions
81
+ topic_subscriptions
82
+ end
83
+
84
+ # Configure JetStream-specific options (Sidekiq-style)
85
+ #
86
+ # @param opts [Hash] Options hash
87
+ # @option opts [Integer] :retry Number of retries (default: 5)
88
+ # @option opts [Integer] :ack_wait ACK wait timeout in seconds (default: 30)
89
+ # @option opts [Integer] :max_deliver Maximum delivery attempts (default: 5)
90
+ # @option opts [Boolean] :dead_letter Enable DLQ (default: true)
91
+ # @option opts [Integer] :batch_size Batch size for fetching (default: 25)
92
+ #
93
+ # @return [Hash] Merged options
94
+ def jetstream_options(opts = {})
95
+ @jetstream_options ||= {
96
+ retry: 5,
97
+ ack_wait: 30,
98
+ max_deliver: 5,
99
+ dead_letter: true,
100
+ batch_size: 25
101
+ }
102
+ @jetstream_options.merge!(opts) if opts.any?
103
+ @jetstream_options
104
+ end
105
+
106
+ private
107
+
108
+ # Build NATS subject pattern for topic subscription
109
+ # Format: {env}.#{app_name}.{topic_name}
110
+ def build_topic_pattern(topic)
111
+ "#{env_prefix}.#{normalize_topic_name(topic)}"
112
+ end
113
+
114
+ # Build NATS subject pattern for wildcard topic subscription
115
+ # Format: {env}.#{app_name}.{topic_name}.>
116
+ def build_topic_wildcard_pattern(topic)
117
+ "#{env_prefix}.#{normalize_topic_name(topic)}.>"
118
+ end
119
+
120
+ # Get environment prefix for subject patterns
121
+ # @return [String] Environment and app prefix
122
+ def env_prefix
123
+ env = defined?(NatsPubsub) ? NatsPubsub.config.env : 'development'
124
+ app = defined?(NatsPubsub) ? NatsPubsub.config.app_name : 'app'
125
+ "#{env}.#{app}"
126
+ end
127
+
128
+ # Normalize topic name (preserve dots and wildcards)
129
+ # Delegates to Subject class for consistency
130
+ def normalize_topic_name(name)
131
+ require_relative '../core/subject' unless defined?(NatsPubsub::Subject)
132
+ Subject.normalize_topic(name)
133
+ end
134
+ end
135
+
136
+ module InstanceMethods
137
+ # Override this method in your subscriber class to handle messages
138
+ #
139
+ # @param message [Hash] The message payload
140
+ # @param context [MessageContext] Message context with event_id, trace_id, deliveries, etc.
141
+ #
142
+ # @raise [NotImplementedError] if not overridden
143
+ def handle(message, context)
144
+ raise NotImplementedError, "#{self.class.name} must implement #handle(message, context)"
145
+ end
146
+
147
+ # Optional: Override this method to provide custom error handling
148
+ # Return an ErrorAction constant to control how errors are handled
149
+ #
150
+ # @param error_context [Core::ErrorContext] Error context with error, message, context, attempts
151
+ # @return [Symbol] Error action (:retry, :discard, :dlq)
152
+ #
153
+ # @example Custom error handling
154
+ # def on_error(error_context)
155
+ # case error_context.error
156
+ # when ValidationError
157
+ # Core::ErrorAction::DISCARD
158
+ # when NetworkError
159
+ # Core::ErrorAction::RETRY
160
+ # else
161
+ # Core::ErrorAction::DLQ
162
+ # end
163
+ # end
164
+ def on_error(error_context)
165
+ # Default implementation - delegates to error handler
166
+ # Subclasses can override for custom behavior
167
+ nil
168
+ end
169
+
170
+ # Access to logger
171
+ #
172
+ # @return [Logger] Logger instance
173
+ def logger
174
+ NatsPubsub.config.logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
175
+ end
176
+
177
+ # Helper method to check if message is from a specific topic
178
+ # @param context [MessageContext] Message context
179
+ # @param topic_name [String] Topic name to check
180
+ # @return [Boolean]
181
+ def from_topic?(context, topic_name)
182
+ context.topic == topic_name.to_s
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/logging'
4
+ require_relative '../core/duration'
5
+
6
+ module NatsPubsub
7
+ module Subscribers
8
+ # Encapsulates durable ensure + subscribe for a pull consumer.
9
+ class SubscriptionManager
10
+ def initialize(jts, durable, cfg = NatsPubsub.config, filter_subject: nil)
11
+ @jts = jts
12
+ @durable = durable
13
+ @cfg = cfg
14
+ @filter_subject = filter_subject || default_filter_subject
15
+ @desired_cfg = build_consumer_config(@durable, @filter_subject)
16
+ @desired_cfg_norm = normalize_consumer_config(@desired_cfg)
17
+ end
18
+
19
+ def stream_name
20
+ @cfg.stream_name
21
+ end
22
+
23
+ attr_reader :filter_subject
24
+
25
+ def desired_consumer_cfg
26
+ @desired_cfg
27
+ end
28
+
29
+ def ensure_consumer!
30
+ info = consumer_info_or_nil
31
+ return create_consumer! unless info
32
+
33
+ have_norm = normalize_consumer_config(info.config)
34
+ if have_norm == @desired_cfg_norm
35
+ log_consumer_ok
36
+ else
37
+ log_consumer_diff(have_norm)
38
+ recreate_consumer!
39
+ end
40
+ end
41
+
42
+ # Bind a pull subscriber to the existing durable.
43
+ def subscribe!
44
+ @jts.pull_subscribe(
45
+ filter_subject,
46
+ @durable,
47
+ stream: stream_name,
48
+ config: desired_consumer_cfg
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def default_filter_subject
55
+ "#{@cfg.env}.events.>" # Subscribe to all PubSub events
56
+ end
57
+
58
+ def consumer_info_or_nil
59
+ @jts.consumer_info(stream_name, @durable)
60
+ rescue NATS::JetStream::Error
61
+ nil
62
+ end
63
+
64
+ # ---- comparison ----
65
+
66
+ def log_consumer_diff(have_norm)
67
+ want_norm = @desired_cfg_norm
68
+
69
+ diffs = {}
70
+ (have_norm.keys | want_norm.keys).each do |k|
71
+ diffs[k] = { have: have_norm[k], want: want_norm[k] } unless have_norm[k] == want_norm[k]
72
+ end
73
+
74
+ Logging.warn(
75
+ "Consumer #{@durable} config mismatch (filter=#{filter_subject}) diff=#{diffs}",
76
+ tag: 'NatsPubsub::Subscribers::SubscriptionManager'
77
+ )
78
+ end
79
+
80
+ def build_consumer_config(durable, filter_subject)
81
+ {
82
+ durable_name: durable,
83
+ filter_subject: filter_subject,
84
+ ack_policy: 'explicit',
85
+ deliver_policy: 'all',
86
+ max_deliver: @cfg.max_deliver,
87
+ ack_wait: Duration.to_millis(@cfg.ack_wait),
88
+ backoff: Array(@cfg.backoff).map { |d| Duration.to_millis(d) }
89
+ }
90
+ end
91
+
92
+ # Normalize both server-returned config objects and our desired hash
93
+ # into a common hash with consistent units/types for accurate comparison.
94
+ def normalize_consumer_config(cfg)
95
+ {
96
+ filter_subject: sval(cfg, :filter_subject), # string
97
+ ack_policy: sval(cfg, :ack_policy), # string
98
+ deliver_policy: sval(cfg, :deliver_policy), # string
99
+ max_deliver: ival(cfg, :max_deliver), # integer
100
+ ack_wait: d_ms(cfg, :ack_wait), # integer ms
101
+ backoff: darr_ms(cfg, :backoff) # array of integer ms
102
+ }
103
+ end
104
+
105
+ # ---- lifecycle helpers ----
106
+
107
+ def recreate_consumer!
108
+ Logging.warn(
109
+ "Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
110
+ tag: 'NatsPubsub::Subscribers::SubscriptionManager'
111
+ )
112
+ safe_delete_consumer
113
+ create_consumer!
114
+ end
115
+
116
+ def create_consumer!
117
+ @jts.add_consumer(stream_name, **desired_consumer_cfg)
118
+ Logging.info(
119
+ "Created consumer #{@durable} (filter=#{filter_subject})",
120
+ tag: 'NatsPubsub::Subscribers::SubscriptionManager'
121
+ )
122
+ end
123
+
124
+ def log_consumer_ok
125
+ Logging.info(
126
+ "Consumer #{@durable} exists with desired config.",
127
+ tag: 'NatsPubsub::Subscribers::SubscriptionManager'
128
+ )
129
+ end
130
+
131
+ def safe_delete_consumer
132
+ @jts.delete_consumer(stream_name, @durable)
133
+ rescue NATS::JetStream::Error => e
134
+ Logging.warn(
135
+ "Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
136
+ tag: 'NatsPubsub::Subscribers::SubscriptionManager'
137
+ )
138
+ end
139
+
140
+ # ---- cfg access/normalization (struct-like or hash-like) ----
141
+
142
+ def get(cfg, key)
143
+ # First try hash-like access, then method access
144
+ # This avoids calling Hash#key or other built-in methods unintentionally
145
+ if cfg.is_a?(Hash) || cfg.respond_to?(:[])
146
+ cfg[key]
147
+ elsif cfg.respond_to?(key)
148
+ cfg.public_send(key)
149
+ end
150
+ end
151
+
152
+ def sval(cfg, key)
153
+ v = get(cfg, key)
154
+ v = v.to_s if v.is_a?(Symbol)
155
+ v&.to_s&.downcase
156
+ end
157
+
158
+ def ival(cfg, key)
159
+ v = get(cfg, key)
160
+ v.to_i
161
+ end
162
+
163
+ # Normalize duration-like field to **milliseconds** (Integer).
164
+ # Accepts:
165
+ # - Strings:"500ms""30s" "2m", "1h", "250us", "100ns"
166
+ # - Integers/Floats:
167
+ # * Server may return large integers in **nanoseconds** → detect and convert.
168
+ # * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
169
+ def d_ms(cfg, key)
170
+ raw = get(cfg, key)
171
+ duration_to_ms(raw)
172
+ end
173
+
174
+ # Normalize array of durations to integer milliseconds.
175
+ def darr_ms(cfg, key)
176
+ raw = get(cfg, key)
177
+ Array(raw).map { |d| duration_to_ms(d) }
178
+ end
179
+
180
+ # ---- duration coercion ----
181
+
182
+ def duration_to_ms(val)
183
+ return nil if val.nil?
184
+
185
+ case val
186
+ when Integer
187
+ # Heuristic: extremely large integers are likely **nanoseconds** from server
188
+ # (e.g., 30s => 30_000_000_000 ns). Convert ns → ms.
189
+ return (val / 1_000_000.0).round if val >= 1_000_000_000
190
+
191
+ # otherwise rely on Duration's :auto heuristic (int <1000 => seconds, >=1000 => ms)
192
+ Duration.to_millis(val, default_unit: :auto)
193
+ when Float
194
+ Duration.to_millis(val, default_unit: :auto) # treated as seconds
195
+ when String
196
+ # Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
197
+ Duration.to_millis(val) # default_unit ignored when unit given
198
+ else
199
+ return Duration.to_millis(val.to_f, default_unit: :auto) if val.respond_to?(:to_f)
200
+
201
+ raise ArgumentError, "invalid duration: #{val.inspect}"
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+ require 'securerandom'
5
+ require_relative '../core/connection'
6
+ require_relative '../core/duration'
7
+ require_relative '../core/logging'
8
+ require_relative '../core/config'
9
+ require_relative '../models/model_utils'
10
+ require_relative 'message_processor'
11
+ require_relative 'subscription_manager'
12
+ require_relative 'inbox/inbox_processor'
13
+
14
+ module NatsPubsub
15
+ module Subscribers
16
+ # Worker that pulls messages from NATS JetStream and processes them.
17
+ # Formerly known as Consumer - renamed for consistency with Subscriber terminology.
18
+ class Worker
19
+ DEFAULT_BATCH_SIZE = 25
20
+ FETCH_TIMEOUT_SECS = 5
21
+ IDLE_SLEEP_SECS = 0.05
22
+ MAX_IDLE_BACKOFF_SECS = 1.0
23
+
24
+ def initialize(durable_name: nil, batch_size: nil, filter_subject: nil, &block)
25
+ raise ArgumentError, 'handler block required' unless block_given?
26
+
27
+ @handler = block
28
+ @batch_size = Integer(batch_size || DEFAULT_BATCH_SIZE)
29
+ @durable = durable_name || NatsPubsub.config.durable_name
30
+ @filter_subject = filter_subject
31
+ @idle_backoff = IDLE_SLEEP_SECS
32
+ @running = true
33
+ @jts = Connection.connect!
34
+
35
+ ensure_destination! unless @filter_subject
36
+
37
+ @sub_mgr = SubscriptionManager.new(
38
+ @jts,
39
+ @durable,
40
+ NatsPubsub.config,
41
+ filter_subject: @filter_subject
42
+ )
43
+ @processor = MessageProcessor.new(@jts, @handler)
44
+ @inbox_proc = InboxProcessor.new(@processor) if NatsPubsub.config.use_inbox
45
+
46
+ ensure_subscription!
47
+ end
48
+
49
+ def run!
50
+ Logging.info(
51
+ "Subscriber worker #{@durable} started " \
52
+ "(batch=#{@batch_size}, dest=#{NatsPubsub.config.destination_subject})…",
53
+ tag: 'NatsPubsub::Subscribers::Worker'
54
+ )
55
+ while @running
56
+ processed = process_batch
57
+ idle_sleep(processed)
58
+ end
59
+ end
60
+
61
+ # Allow external callers to stop a long-running loop gracefully.
62
+ def stop!
63
+ @running = false
64
+ end
65
+
66
+ private
67
+
68
+ def ensure_destination!
69
+ return unless NatsPubsub.config.destination_app.to_s.empty?
70
+
71
+ raise ArgumentError, 'destination_app must be configured'
72
+ end
73
+
74
+ def ensure_subscription!
75
+ @sub_mgr.ensure_consumer!
76
+ @psub = @sub_mgr.subscribe!
77
+ end
78
+
79
+ # Returns number of messages processed; 0 on timeout/idle or after recovery.
80
+ def process_batch
81
+ msgs = fetch_messages
82
+ return 0 if msgs.nil? || msgs.empty?
83
+
84
+ msgs.sum { |m| process_one(m) }
85
+ rescue NATS::Timeout, NATS::IO::Timeout
86
+ 0
87
+ rescue NATS::JetStream::Error => e
88
+ handle_js_error(e)
89
+ rescue StandardError => e
90
+ Logging.error("Unexpected process_batch error: #{e.class} #{e.message}", tag: 'NatsPubsub::Subscribers::Worker')
91
+ 0
92
+ end
93
+
94
+ # --- helpers ---
95
+
96
+ def fetch_messages
97
+ @psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
98
+ end
99
+
100
+ def process_one(msg)
101
+ if @inbox_proc
102
+ @inbox_proc.process(msg) ? 1 : 0
103
+ else
104
+ @processor.handle_message(msg)
105
+ 1
106
+ end
107
+ rescue StandardError => e
108
+ # Safety: never let a single bad message kill the batch loop.
109
+ Logging.error("Message processing crashed: #{e.class} #{e.message}", tag: 'NatsPubsub::Subscribers::Worker')
110
+ 0
111
+ end
112
+
113
+ def handle_js_error(error)
114
+ if recoverable_consumer_error?(error)
115
+ Logging.warn(
116
+ "Recovering subscription after error: #{error.class} #{error.message}",
117
+ tag: 'NatsPubsub::Subscribers::Worker'
118
+ )
119
+ ensure_subscription!
120
+ else
121
+ Logging.error("Fetch failed (non-recoverable): #{error.class} #{error.message}", tag: 'NatsPubsub::Subscribers::Worker')
122
+ end
123
+ 0
124
+ end
125
+
126
+ def recoverable_consumer_error?(error)
127
+ msg = error.message.to_s
128
+ code = js_err_code(msg)
129
+ # Heuristics: consumer/stream missing, no responders, or common 404-ish cases
130
+ msg =~ /consumer.*(not\s+found|deleted)/i ||
131
+ msg =~ /no\s+responders/i ||
132
+ msg =~ /stream.*not\s+found/i ||
133
+ code == 404
134
+ end
135
+
136
+ def js_err_code(message)
137
+ m = message.match(/err_code=(\d{3,5})/)
138
+ m ? m[1].to_i : nil
139
+ end
140
+
141
+ def idle_sleep(processed)
142
+ if processed.zero?
143
+ # exponential-ish backoff with a tiny jitter to avoid sync across workers
144
+ @idle_backoff = [@idle_backoff * 1.5, MAX_IDLE_BACKOFF_SECS].min
145
+ sleep(@idle_backoff + (rand * 0.01))
146
+ else
147
+ @idle_backoff = IDLE_SLEEP_SECS
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :nats_pubsub do
4
+ desc 'Install NatsPubsub (initializer + migrations)'
5
+ task install: :environment do
6
+ puts '[nats_pubsub] Generating initializer and migrations...'
7
+ Rails::Generators.invoke('nats_pubsub:install', [], behavior: :invoke, destination_root: Rails.root.to_s)
8
+ puts '[nats_pubsub] Done.'
9
+ end
10
+ end