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