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,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Include this concern in your ActiveRecord models to automatically publish events
|
|
6
|
+
#
|
|
7
|
+
# @example Basic usage
|
|
8
|
+
# class User < ApplicationRecord
|
|
9
|
+
# include NatsPubsub::ActiveRecord::Publishable
|
|
10
|
+
#
|
|
11
|
+
# publishes_events domain: 'users', resource: 'user'
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Advanced usage with conditional publishing
|
|
15
|
+
# class Order < ApplicationRecord
|
|
16
|
+
# include NatsPubsub::ActiveRecord::Publishable
|
|
17
|
+
#
|
|
18
|
+
# publishes_events domain: 'orders',
|
|
19
|
+
# resource: 'order',
|
|
20
|
+
# on_create: true,
|
|
21
|
+
# on_update: -> { status_changed? },
|
|
22
|
+
# on_destroy: false,
|
|
23
|
+
# if: :should_publish?,
|
|
24
|
+
# except: [:internal_notes]
|
|
25
|
+
#
|
|
26
|
+
# def should_publish?
|
|
27
|
+
# !imported?
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
module Publishable
|
|
31
|
+
extend ActiveSupport::Concern
|
|
32
|
+
|
|
33
|
+
# Default sensitive attributes to exclude from events
|
|
34
|
+
DEFAULT_SENSITIVE_ATTRIBUTES = %i[
|
|
35
|
+
password password_digest encrypted_password
|
|
36
|
+
reset_password_token reset_password_sent_at
|
|
37
|
+
remember_created_at confirmation_token
|
|
38
|
+
unlock_token otp_secret_key otp_backup_codes
|
|
39
|
+
api_key api_secret access_token refresh_token
|
|
40
|
+
ssn credit_card_number bank_account
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
included do
|
|
44
|
+
class_attribute :publish_config, default: {}
|
|
45
|
+
class_attribute :sensitive_attributes, default: DEFAULT_SENSITIVE_ATTRIBUTES
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class_methods do
|
|
49
|
+
# Configure event publishing for this model
|
|
50
|
+
#
|
|
51
|
+
# @param domain [String] Domain for pubsub subject (default: pluralized model name)
|
|
52
|
+
# @param resource [String] Resource type (default: underscored model name)
|
|
53
|
+
# @param options [Hash] Additional options
|
|
54
|
+
# @option options [Boolean, Proc] :on_create Publish created events (default: true)
|
|
55
|
+
# @option options [Boolean, Proc] :on_update Publish updated events (default: true)
|
|
56
|
+
# @option options [Boolean, Proc] :on_destroy Publish deleted events (default: true)
|
|
57
|
+
# @option options [Symbol, Proc] :if Conditional publishing
|
|
58
|
+
# @option options [Symbol, Proc] :unless Conditional publishing (inverted)
|
|
59
|
+
# @option options [Array<Symbol>] :only Whitelist attributes to publish
|
|
60
|
+
# @option options [Array<Symbol>] :except Blacklist attributes (in addition to sensitive)
|
|
61
|
+
# @option options [Symbol] :error_handler Custom error handler method name
|
|
62
|
+
def publishes_events(domain: nil, resource: nil, **options)
|
|
63
|
+
self.publish_config = {
|
|
64
|
+
domain: domain || name.underscore.pluralize,
|
|
65
|
+
resource: resource || name.underscore,
|
|
66
|
+
on_create: options.fetch(:on_create, true),
|
|
67
|
+
on_update: options.fetch(:on_update, true),
|
|
68
|
+
on_destroy: options.fetch(:on_destroy, true),
|
|
69
|
+
if: options[:if],
|
|
70
|
+
unless: options[:unless],
|
|
71
|
+
only: options[:only],
|
|
72
|
+
except: options[:except],
|
|
73
|
+
error_handler: options[:error_handler] || :handle_publish_error
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setup_callbacks
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Add custom sensitive attributes
|
|
80
|
+
#
|
|
81
|
+
# @param attributes [Array<Symbol>] Attributes to exclude from publishing
|
|
82
|
+
def exclude_from_publishing(*attributes)
|
|
83
|
+
self.sensitive_attributes = sensitive_attributes + attributes.flatten
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def setup_callbacks
|
|
89
|
+
setup_create_callback if publish_config[:on_create]
|
|
90
|
+
setup_update_callback if publish_config[:on_update]
|
|
91
|
+
setup_destroy_callback if publish_config[:on_destroy]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def setup_create_callback
|
|
95
|
+
condition = publish_config[:on_create]
|
|
96
|
+
after_commit :publish_created_event, on: :create,
|
|
97
|
+
if: -> { should_publish_event?(:on_create, condition) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def setup_update_callback
|
|
101
|
+
condition = publish_config[:on_update]
|
|
102
|
+
after_commit :publish_updated_event, on: :update,
|
|
103
|
+
if: -> { saved_changes? && should_publish_event?(:on_update, condition) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def setup_destroy_callback
|
|
107
|
+
condition = publish_config[:on_destroy]
|
|
108
|
+
after_commit :publish_deleted_event, on: :destroy,
|
|
109
|
+
if: -> { should_publish_event?(:on_destroy, condition) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def publish_created_event
|
|
116
|
+
publish_event('created')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def publish_updated_event
|
|
120
|
+
publish_event('updated', changes: previous_changes.keys)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def publish_deleted_event
|
|
124
|
+
publish_event('deleted')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def publish_event(action, extra = {})
|
|
128
|
+
config = self.class.publish_config
|
|
129
|
+
domain = config[:domain]
|
|
130
|
+
resource = config[:resource]
|
|
131
|
+
|
|
132
|
+
payload = publishable_attributes.merge(extra)
|
|
133
|
+
|
|
134
|
+
NatsPubsub.publish(domain, resource, action, **payload)
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
# Call custom error handler if defined
|
|
137
|
+
error_handler = config[:error_handler]
|
|
138
|
+
if error_handler && respond_to?(error_handler, true)
|
|
139
|
+
send(error_handler, e, action, payload)
|
|
140
|
+
else
|
|
141
|
+
handle_publish_error(e, action, payload)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def handle_publish_error(error, action, payload)
|
|
146
|
+
# Default error handler - log but don't fail
|
|
147
|
+
return unless defined?(Rails) && Rails.logger
|
|
148
|
+
|
|
149
|
+
Rails.logger.error(
|
|
150
|
+
"[NatsPubsub::Publishable] Failed to publish #{self.class.name}.#{action}: #{error.message}"
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def should_publish_event?(event_type, condition)
|
|
155
|
+
# Check global if/unless conditions
|
|
156
|
+
config = self.class.publish_config
|
|
157
|
+
return false if config[:unless] && evaluate_condition(config[:unless])
|
|
158
|
+
return false if config[:if] && !evaluate_condition(config[:if])
|
|
159
|
+
|
|
160
|
+
# Check event-specific condition
|
|
161
|
+
return true if condition == true
|
|
162
|
+
return false if condition == false
|
|
163
|
+
|
|
164
|
+
evaluate_condition(condition) if condition
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def evaluate_condition(condition)
|
|
168
|
+
case condition
|
|
169
|
+
when Symbol
|
|
170
|
+
send(condition)
|
|
171
|
+
when Proc
|
|
172
|
+
instance_eval(&condition)
|
|
173
|
+
else
|
|
174
|
+
!!condition
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def publishable_attributes
|
|
179
|
+
attrs = attributes.symbolize_keys
|
|
180
|
+
config = self.class.publish_config
|
|
181
|
+
|
|
182
|
+
# Handle :only option (whitelist)
|
|
183
|
+
attrs = attrs.slice(*config[:only]) if config[:only]
|
|
184
|
+
|
|
185
|
+
# Exclude sensitive and custom blacklist
|
|
186
|
+
excluded = self.class.sensitive_attributes
|
|
187
|
+
excluded += config[:except] if config[:except]
|
|
188
|
+
attrs.except(*excluded)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'core/logging'
|
|
4
|
+
require_relative 'subscribers/registry'
|
|
5
|
+
|
|
6
|
+
module NatsPubsub
|
|
7
|
+
# CLI for running NatsPubsub subscribers
|
|
8
|
+
class CLI
|
|
9
|
+
def initialize(options = {})
|
|
10
|
+
@options = options
|
|
11
|
+
@pool = nil
|
|
12
|
+
@running = true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
setup_environment
|
|
17
|
+
setup_signal_handlers
|
|
18
|
+
discover_subscribers
|
|
19
|
+
start_pool
|
|
20
|
+
wait_for_shutdown
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def setup_environment
|
|
26
|
+
ENV['RAILS_ENV'] = ENV['RACK_ENV'] = @options[:environment] if @options[:environment]
|
|
27
|
+
|
|
28
|
+
if @options[:require]
|
|
29
|
+
require File.expand_path(@options[:require])
|
|
30
|
+
elsif File.exist?('config/environment.rb')
|
|
31
|
+
require File.expand_path('config/environment.rb')
|
|
32
|
+
else
|
|
33
|
+
raise 'Cannot find application. Use -r to specify file to require.'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Logging.info(
|
|
37
|
+
"NatsPubsub starting in #{@options[:environment] || ENV['RAILS_ENV'] || 'development'} environment",
|
|
38
|
+
tag: 'NatsPubsub::CLI'
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def discover_subscribers
|
|
43
|
+
Subscribers::Registry.instance.discover_subscribers!
|
|
44
|
+
|
|
45
|
+
subscribers = Subscribers::Registry.instance.all_subscribers
|
|
46
|
+
return unless subscribers.empty?
|
|
47
|
+
|
|
48
|
+
Logging.warn(
|
|
49
|
+
'No subscribers found in app/subscribers/',
|
|
50
|
+
tag: 'NatsPubsub::CLI'
|
|
51
|
+
)
|
|
52
|
+
exit(1)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start_pool
|
|
56
|
+
# Load Pool (lazy load to avoid circular dependencies)
|
|
57
|
+
require_relative 'subscribers/pool'
|
|
58
|
+
|
|
59
|
+
concurrency = @options[:concurrency] || NatsPubsub.config.concurrency || 5
|
|
60
|
+
@pool = Subscribers::Pool.new(concurrency: concurrency)
|
|
61
|
+
|
|
62
|
+
Thread.new do
|
|
63
|
+
@pool.start!
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def setup_signal_handlers
|
|
68
|
+
%w[INT TERM].each do |signal|
|
|
69
|
+
trap(signal) do
|
|
70
|
+
Logging.info(
|
|
71
|
+
"Received #{signal}, shutting down gracefully...",
|
|
72
|
+
tag: 'NatsPubsub::CLI'
|
|
73
|
+
)
|
|
74
|
+
@running = false
|
|
75
|
+
@pool&.stop!
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
trap('USR1') do
|
|
80
|
+
Logging.info('Thread dump:', tag: 'NatsPubsub::CLI')
|
|
81
|
+
Thread.list.each do |thread|
|
|
82
|
+
Logging.info(
|
|
83
|
+
"#{thread.name || thread.object_id}: #{thread.status}",
|
|
84
|
+
tag: 'NatsPubsub::CLI'
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
rescue ArgumentError => e
|
|
89
|
+
# Some systems don't support USR1
|
|
90
|
+
Logging.warn("Could not setup USR1 signal handler: #{e.message}", tag: 'NatsPubsub::CLI')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def wait_for_shutdown
|
|
94
|
+
sleep 0.5 while @running
|
|
95
|
+
|
|
96
|
+
Logging.info(
|
|
97
|
+
'Waiting for in-flight messages to complete...',
|
|
98
|
+
tag: 'NatsPubsub::CLI'
|
|
99
|
+
)
|
|
100
|
+
sleep 2
|
|
101
|
+
|
|
102
|
+
Logging.info('Shutdown complete', tag: 'NatsPubsub::CLI')
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../models/model_utils'
|
|
4
|
+
require_relative 'logging'
|
|
5
|
+
|
|
6
|
+
module NatsPubsub
|
|
7
|
+
# Base repository class with common persistence patterns.
|
|
8
|
+
# Follows Template Method pattern for shared persistence flow.
|
|
9
|
+
# Extracted to DRY up InboxRepository and OutboxRepository.
|
|
10
|
+
class BaseRepository
|
|
11
|
+
attr_reader :model_class
|
|
12
|
+
|
|
13
|
+
def initialize(model_class)
|
|
14
|
+
@model_class = model_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Find or build a record by event_id
|
|
18
|
+
#
|
|
19
|
+
# @param event_id [String] Event identifier
|
|
20
|
+
# @return [ActiveRecord::Base] Record instance
|
|
21
|
+
def find_or_build(event_id)
|
|
22
|
+
ModelUtils.find_or_init_by_best(
|
|
23
|
+
model_class,
|
|
24
|
+
{ event_id: event_id },
|
|
25
|
+
{ dedup_key: event_id }
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
protected
|
|
30
|
+
|
|
31
|
+
# Assign attributes to record safely
|
|
32
|
+
#
|
|
33
|
+
# @param record [ActiveRecord::Base] Record instance
|
|
34
|
+
# @param attrs [Hash] Attributes to assign
|
|
35
|
+
def assign_attributes(record, attrs)
|
|
36
|
+
ModelUtils.assign_known_attrs(record, attrs)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Save record with error handling
|
|
40
|
+
#
|
|
41
|
+
# @param record [ActiveRecord::Base] Record instance
|
|
42
|
+
# @raise [ActiveRecord::RecordInvalid] if save fails
|
|
43
|
+
def save_record!(record)
|
|
44
|
+
record.save!
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Logging.error(
|
|
47
|
+
"Failed to save #{model_class.name}: #{e.class} #{e.message}",
|
|
48
|
+
tag: 'NatsPubsub::BaseRepository'
|
|
49
|
+
)
|
|
50
|
+
raise
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Update record with timestamp
|
|
54
|
+
#
|
|
55
|
+
# @param record [ActiveRecord::Base] Record instance
|
|
56
|
+
# @param attrs [Hash] Attributes to update
|
|
57
|
+
# @param timestamp [Time] Timestamp to use
|
|
58
|
+
def update_with_timestamp(record, attrs, timestamp = Time.now.utc)
|
|
59
|
+
attrs[:updated_at] = timestamp if record.respond_to?(:updated_at)
|
|
60
|
+
assign_attributes(record, attrs)
|
|
61
|
+
save_record!(record)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if attribute exists on record
|
|
65
|
+
#
|
|
66
|
+
# @param record [ActiveRecord::Base] Record instance
|
|
67
|
+
# @param attribute [Symbol] Attribute name
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def has_attribute?(record, attribute)
|
|
70
|
+
record.respond_to?(attribute)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'subject'
|
|
4
|
+
require_relative 'constants'
|
|
5
|
+
|
|
6
|
+
module NatsPubsub
|
|
7
|
+
class Config
|
|
8
|
+
attr_accessor :nats_urls, :env, :app_name, :destination_app,
|
|
9
|
+
:max_deliver, :ack_wait, :backoff,
|
|
10
|
+
:use_outbox, :use_inbox, :inbox_model, :outbox_model,
|
|
11
|
+
:use_dlq, :dlq_max_attempts, :dlq_stream_suffix,
|
|
12
|
+
:logger, :concurrency,
|
|
13
|
+
:connection_pool_size, :connection_pool_timeout
|
|
14
|
+
attr_reader :preset
|
|
15
|
+
|
|
16
|
+
def initialize(preset: nil)
|
|
17
|
+
@preset = preset
|
|
18
|
+
|
|
19
|
+
# Default values (can be overridden by preset)
|
|
20
|
+
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
|
21
|
+
@env = ENV['NATS_ENV'] || 'development'
|
|
22
|
+
@app_name = ENV['APP_NAME'] || 'app'
|
|
23
|
+
@destination_app = ENV.fetch('DESTINATION_APP', nil)
|
|
24
|
+
|
|
25
|
+
@max_deliver = Constants::Retry::MAX_ATTEMPTS
|
|
26
|
+
@ack_wait = "#{Constants::Timeouts::ACK_WAIT_DEFAULT / 1000}s"
|
|
27
|
+
@backoff = Constants::Retry::DEFAULT_BACKOFF.map { |ms| "#{ms}ms" }
|
|
28
|
+
|
|
29
|
+
@use_outbox = false
|
|
30
|
+
@use_inbox = false
|
|
31
|
+
@use_dlq = true
|
|
32
|
+
@dlq_max_attempts = Constants::DLQ::MAX_ATTEMPTS
|
|
33
|
+
@dlq_stream_suffix = Constants::DLQ::STREAM_SUFFIX
|
|
34
|
+
@outbox_model = 'NatsPubsub::OutboxEvent'
|
|
35
|
+
@inbox_model = 'NatsPubsub::InboxEvent'
|
|
36
|
+
@logger = nil
|
|
37
|
+
@concurrency = Constants::Consumer::DEFAULT_CONCURRENCY
|
|
38
|
+
|
|
39
|
+
# Connection pool settings
|
|
40
|
+
@connection_pool_size = ENV.fetch('NATS_POOL_SIZE', 5).to_i
|
|
41
|
+
@connection_pool_timeout = ENV.fetch('NATS_POOL_TIMEOUT', 5).to_i
|
|
42
|
+
|
|
43
|
+
# Middleware chain (lazy loaded to avoid circular dependency)
|
|
44
|
+
@server_middleware = nil
|
|
45
|
+
|
|
46
|
+
# Apply preset if provided
|
|
47
|
+
apply_preset!(preset) if preset
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Stream name per environment
|
|
51
|
+
def stream_name
|
|
52
|
+
"#{env}-events-stream"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# PubSub event subject format
|
|
56
|
+
# Delegates to Subject class for centralized subject building
|
|
57
|
+
# Format: {env}.{app_name}.{domain}.{resource}.{action}
|
|
58
|
+
def event_subject(domain, resource, action)
|
|
59
|
+
Subject.from_event(
|
|
60
|
+
env: env,
|
|
61
|
+
app_name: app_name,
|
|
62
|
+
domain: domain,
|
|
63
|
+
resource: resource,
|
|
64
|
+
action: action
|
|
65
|
+
).to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# DLQ subject for failed messages
|
|
69
|
+
def dlq_subject
|
|
70
|
+
"#{env}.#{app_name}.dlq"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# DLQ stream name
|
|
74
|
+
def dlq_stream_name
|
|
75
|
+
"#{stream_name}#{dlq_stream_suffix}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Durable consumer name
|
|
79
|
+
def durable_name
|
|
80
|
+
"#{env}-#{app_name}-workers"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Access/configure server middleware
|
|
84
|
+
def server_middleware
|
|
85
|
+
@server_middleware ||= begin
|
|
86
|
+
require_relative '../middleware/chain'
|
|
87
|
+
Middleware::Chain.new
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
yield @server_middleware if block_given?
|
|
91
|
+
@server_middleware
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Apply a configuration preset
|
|
95
|
+
#
|
|
96
|
+
# @param preset_name [Symbol] Preset name (:development, :production, :testing)
|
|
97
|
+
# @raise [ArgumentError] if preset is unknown
|
|
98
|
+
# @return [void]
|
|
99
|
+
def apply_preset!(preset_name)
|
|
100
|
+
require_relative 'config_presets'
|
|
101
|
+
ConfigPresets.apply!(self, preset_name)
|
|
102
|
+
@preset = preset_name
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Validate configuration values
|
|
106
|
+
# Raises ConfigurationError if invalid
|
|
107
|
+
#
|
|
108
|
+
# @raise [ConfigurationError] if configuration is invalid
|
|
109
|
+
# @return [void]
|
|
110
|
+
def validate!
|
|
111
|
+
validate_required_fields!
|
|
112
|
+
validate_numeric_ranges!
|
|
113
|
+
validate_urls!
|
|
114
|
+
validate_concurrency_bounds!
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def validate_required_fields!
|
|
120
|
+
raise ConfigurationError, 'app_name cannot be blank' if app_name.nil? || app_name.to_s.strip.empty?
|
|
121
|
+
raise ConfigurationError, 'env cannot be blank' if env.nil? || env.to_s.strip.empty?
|
|
122
|
+
raise ConfigurationError, 'nats_urls cannot be empty' if nats_urls.nil? || nats_urls.empty?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_numeric_ranges!
|
|
126
|
+
raise ConfigurationError, 'concurrency must be positive' if concurrency && concurrency <= 0
|
|
127
|
+
raise ConfigurationError, 'max_deliver must be positive' if max_deliver && max_deliver <= 0
|
|
128
|
+
raise ConfigurationError, 'dlq_max_attempts must be positive' if dlq_max_attempts && dlq_max_attempts <= 0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_concurrency_bounds!
|
|
132
|
+
return unless concurrency
|
|
133
|
+
|
|
134
|
+
min = Constants::Consumer::MIN_CONCURRENCY
|
|
135
|
+
max = Constants::Consumer::MAX_CONCURRENCY
|
|
136
|
+
|
|
137
|
+
if concurrency < min
|
|
138
|
+
raise ConfigurationError, "concurrency must be at least #{min}, got #{concurrency}"
|
|
139
|
+
elsif concurrency > max
|
|
140
|
+
raise ConfigurationError, "concurrency cannot exceed #{max}, got #{concurrency}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_urls!
|
|
145
|
+
return unless nats_urls
|
|
146
|
+
|
|
147
|
+
Array(nats_urls).each do |url|
|
|
148
|
+
raise ConfigurationError, "Invalid NATS URL: #{url}" unless url =~ %r{\Anats://}i
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'constants'
|
|
4
|
+
|
|
5
|
+
module NatsPubsub
|
|
6
|
+
# Configuration presets for common deployment scenarios
|
|
7
|
+
# Provides smart defaults for development, production, and testing environments
|
|
8
|
+
#
|
|
9
|
+
# @example Using a preset
|
|
10
|
+
# NatsPubsub.setup_with_preset!(:production) do |config|
|
|
11
|
+
# config.nats_urls = ENV['NATS_URLS']
|
|
12
|
+
# config.app_name = 'my-app'
|
|
13
|
+
# end
|
|
14
|
+
class ConfigPresets
|
|
15
|
+
class << self
|
|
16
|
+
# Apply a preset to a configuration object
|
|
17
|
+
#
|
|
18
|
+
# @param config [Config] Configuration object to modify
|
|
19
|
+
# @param preset [Symbol] Preset name (:development, :production, :testing)
|
|
20
|
+
# @raise [ArgumentError] if preset is unknown
|
|
21
|
+
def apply!(config, preset)
|
|
22
|
+
case preset
|
|
23
|
+
when :development
|
|
24
|
+
apply_development!(config)
|
|
25
|
+
when :production
|
|
26
|
+
apply_production!(config)
|
|
27
|
+
when :testing, :test
|
|
28
|
+
apply_testing!(config)
|
|
29
|
+
else
|
|
30
|
+
raise ArgumentError, "Unknown preset: #{preset}. Available: :development, :production, :testing"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get preset description
|
|
35
|
+
#
|
|
36
|
+
# @param preset [Symbol] Preset name
|
|
37
|
+
# @return [String] Description of the preset
|
|
38
|
+
def description(preset)
|
|
39
|
+
DESCRIPTIONS[preset] || "Unknown preset: #{preset}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# List all available presets
|
|
43
|
+
#
|
|
44
|
+
# @return [Array<Symbol>] Available preset names
|
|
45
|
+
def available_presets
|
|
46
|
+
%i[development production testing]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Development preset - optimized for local development
|
|
52
|
+
# - Verbose logging for debugging
|
|
53
|
+
# - Lower concurrency to avoid resource exhaustion
|
|
54
|
+
# - DLQ enabled for debugging failed messages
|
|
55
|
+
# - Shorter timeouts for faster feedback
|
|
56
|
+
# - Outbox/Inbox disabled by default (can enable for testing)
|
|
57
|
+
def apply_development!(config)
|
|
58
|
+
config.env = 'development' unless config.env
|
|
59
|
+
config.concurrency = Constants::Consumer::DEFAULT_CONCURRENCY
|
|
60
|
+
config.max_deliver = 3 # Fail faster in development
|
|
61
|
+
config.ack_wait = '10s' # Shorter timeout for faster feedback
|
|
62
|
+
config.backoff = %w[500ms 2s 5s] # Faster retries
|
|
63
|
+
|
|
64
|
+
# Features
|
|
65
|
+
config.use_dlq = true
|
|
66
|
+
config.use_outbox = false
|
|
67
|
+
config.use_inbox = false
|
|
68
|
+
config.dlq_max_attempts = 2 # Fail to DLQ faster for debugging
|
|
69
|
+
|
|
70
|
+
# Logging - verbose for development
|
|
71
|
+
config.logger ||= create_logger(:debug)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Production preset - optimized for reliability and performance
|
|
75
|
+
# - Error-level logging to reduce noise
|
|
76
|
+
# - Higher concurrency for throughput
|
|
77
|
+
# - DLQ enabled for failure recovery
|
|
78
|
+
# - Longer timeouts for stability
|
|
79
|
+
# - Outbox/Inbox available (must explicitly enable)
|
|
80
|
+
def apply_production!(config)
|
|
81
|
+
config.env = 'production' unless config.env
|
|
82
|
+
config.concurrency = 20 # Higher throughput
|
|
83
|
+
config.max_deliver = Constants::Retry::MAX_ATTEMPTS
|
|
84
|
+
config.ack_wait = "#{Constants::Timeouts::ACK_WAIT_DEFAULT / 1000}s"
|
|
85
|
+
config.backoff = Constants::Retry::DEFAULT_BACKOFF.map { |ms| "#{ms}ms" }
|
|
86
|
+
|
|
87
|
+
# Features
|
|
88
|
+
config.use_dlq = true
|
|
89
|
+
config.use_outbox = false # Explicitly enable when needed
|
|
90
|
+
config.use_inbox = false # Explicitly enable when needed
|
|
91
|
+
config.dlq_max_attempts = Constants::DLQ::MAX_ATTEMPTS
|
|
92
|
+
|
|
93
|
+
# Logging - errors only in production
|
|
94
|
+
config.logger ||= create_logger(:error)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Testing preset - optimized for test suite performance
|
|
98
|
+
# - Synchronous processing (no background workers)
|
|
99
|
+
# - Minimal logging to avoid test output noise
|
|
100
|
+
# - DLQ disabled (tests should verify behavior directly)
|
|
101
|
+
# - Fast timeouts
|
|
102
|
+
# - Fake mode enabled by default
|
|
103
|
+
def apply_testing!(config)
|
|
104
|
+
config.env = 'test' unless config.env
|
|
105
|
+
config.concurrency = 1 # Synchronous processing
|
|
106
|
+
config.max_deliver = 2 # Fail fast in tests
|
|
107
|
+
config.ack_wait = '1s' # Fast timeout
|
|
108
|
+
config.backoff = %w[100ms 500ms] # Minimal retries
|
|
109
|
+
|
|
110
|
+
# Features - disabled for speed
|
|
111
|
+
config.use_dlq = false
|
|
112
|
+
config.use_outbox = false
|
|
113
|
+
config.use_inbox = false
|
|
114
|
+
config.dlq_max_attempts = 1
|
|
115
|
+
|
|
116
|
+
# Logging - minimal for tests
|
|
117
|
+
config.logger ||= create_logger(:fatal) # Only fatal errors
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Create a logger with specified level
|
|
121
|
+
def create_logger(level)
|
|
122
|
+
require 'logger'
|
|
123
|
+
logger = Logger.new($stdout)
|
|
124
|
+
logger.level = Logger.const_get(level.to_s.upcase)
|
|
125
|
+
logger.formatter = proc do |severity, datetime, _progname, msg|
|
|
126
|
+
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
|
|
127
|
+
end
|
|
128
|
+
logger
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Preset descriptions for documentation
|
|
132
|
+
DESCRIPTIONS = {
|
|
133
|
+
development: 'Optimized for local development with verbose logging and fast feedback',
|
|
134
|
+
production: 'Optimized for reliability and performance in production environments',
|
|
135
|
+
testing: 'Optimized for test suite performance with synchronous processing'
|
|
136
|
+
}.freeze
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|