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,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
|