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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Subscribers
5
+ # Immutable value object representing error context for unified error handling.
6
+ # Provides a consistent interface for capturing and logging error information
7
+ # throughout the message processing pipeline.
8
+ #
9
+ # This class follows the Value Object pattern and encapsulates all error-related
10
+ # metadata, making error handling more consistent and reducing duplication.
11
+ #
12
+ # @example Creating error context from an exception
13
+ # begin
14
+ # process_message(msg)
15
+ # rescue StandardError => e
16
+ # error_ctx = ErrorContext.from_exception(e, reason: 'processing_failed')
17
+ # puts error_ctx.to_log_string
18
+ # end
19
+ #
20
+ # @attr_reader error_class [String] Name of the error class
21
+ # @attr_reader error_message [String] Error message
22
+ # @attr_reader reason [String] High-level reason for the error (e.g., 'malformed_json', 'max_deliver_exceeded')
23
+ # @attr_reader backtrace [Array<String>, nil] Optional error backtrace
24
+ class ErrorContext
25
+ attr_reader :error_class, :error_message, :reason, :backtrace
26
+
27
+ # Initialize a new ErrorContext
28
+ #
29
+ # @param error_class [String] Name of the error class
30
+ # @param error_message [String] Error message
31
+ # @param reason [String] High-level reason for the error
32
+ # @param backtrace [Array<String>, nil] Optional error backtrace
33
+ def initialize(error_class:, error_message:, reason:, backtrace: nil)
34
+ @error_class = error_class.to_s
35
+ @error_message = error_message.to_s
36
+ @reason = reason.to_s
37
+ @backtrace = backtrace
38
+ freeze # Make immutable
39
+ end
40
+
41
+ # Build ErrorContext from an exception
42
+ #
43
+ # Extracts error information from a Ruby exception object and creates
44
+ # a structured error context with optional reason and backtrace.
45
+ #
46
+ # @param exception [Exception] Ruby exception object
47
+ # @param reason [String] High-level reason for the error
48
+ # @param include_backtrace [Boolean] Whether to include the backtrace (default: false)
49
+ # @return [ErrorContext] Immutable error context
50
+ def self.from_exception(exception, reason:, include_backtrace: false)
51
+ new(
52
+ error_class: exception.class.name,
53
+ error_message: exception.message,
54
+ reason: reason,
55
+ backtrace: include_backtrace ? exception.backtrace : nil
56
+ )
57
+ end
58
+
59
+ # Build ErrorContext from keyword arguments
60
+ #
61
+ # Convenience constructor for cases where you have structured error data
62
+ # but not an exception object.
63
+ #
64
+ # @param error_class [String] Name of the error class
65
+ # @param error_message [String] Error message
66
+ # @param reason [String] High-level reason for the error
67
+ # @return [ErrorContext] Immutable error context
68
+ def self.build(error_class:, error_message:, reason:)
69
+ new(
70
+ error_class: error_class,
71
+ error_message: error_message,
72
+ reason: reason
73
+ )
74
+ end
75
+
76
+ # Format error for logging
77
+ #
78
+ # Creates a concise string representation suitable for log messages,
79
+ # following the pattern: "ErrorClass: message"
80
+ #
81
+ # @return [String] Formatted error string
82
+ def to_log_string
83
+ "#{error_class}: #{error_message}"
84
+ end
85
+
86
+ # Hash representation for DLQ publishing
87
+ #
88
+ # Returns error information as a hash suitable for inclusion
89
+ # in DLQ message envelopes.
90
+ #
91
+ # @return [Hash] Error context as hash
92
+ def to_dlq_hash
93
+ {
94
+ error_class: error_class,
95
+ error_message: error_message,
96
+ reason: reason
97
+ }
98
+ end
99
+
100
+ # Complete hash representation
101
+ #
102
+ # @return [Hash] Full context including optional backtrace
103
+ def to_h
104
+ hash = {
105
+ error_class: error_class,
106
+ error_message: error_message,
107
+ reason: reason
108
+ }
109
+ hash[:backtrace] = backtrace if backtrace
110
+ hash
111
+ end
112
+
113
+ # String representation for debugging
114
+ #
115
+ # @return [String] Human-readable error context
116
+ def to_s
117
+ "ErrorContext(reason=#{reason}, error=#{error_class}: #{error_message})"
118
+ end
119
+
120
+ # Check if error is of a specific type
121
+ #
122
+ # @param error_classes [Array<Class>] Error classes to check against
123
+ # @return [Boolean] True if error matches any of the classes
124
+ def is_a?(*error_classes)
125
+ error_classes.any? { |klass| error_class == klass.name }
126
+ end
127
+
128
+ # Check if error matches a specific reason
129
+ #
130
+ # @param target_reason [String, Symbol] Reason to check
131
+ # @return [Boolean] True if reason matches
132
+ def reason?(target_reason)
133
+ reason == target_reason.to_s
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/error_action'
4
+ require_relative '../core/constants'
5
+
6
+ module NatsPubsub
7
+ module Subscribers
8
+ # Enhanced error handler with ErrorAction support
9
+ # Integrates per-subscriber error handling strategies
10
+ class ErrorHandler
11
+ attr_reader :subscriber, :config
12
+
13
+ def initialize(subscriber, config: NatsPubsub.config)
14
+ @subscriber = subscriber
15
+ @config = config
16
+ end
17
+
18
+ # Handle an error from message processing
19
+ #
20
+ # @param error [Exception] The error that occurred
21
+ # @param message [Hash] The message payload
22
+ # @param context [MessageContext] Message metadata
23
+ # @param attempt_number [Integer] Current delivery attempt
24
+ # @return [Symbol] Error action to take (:retry, :discard, :dlq)
25
+ def handle_error(error, message, context, attempt_number)
26
+ error_context = build_error_context(error, message, context, attempt_number)
27
+
28
+ # Try subscriber's custom error handler first
29
+ action = if subscriber.respond_to?(:on_error)
30
+ call_subscriber_error_handler(error_context)
31
+ else
32
+ determine_default_action(error_context)
33
+ end
34
+
35
+ # Validate and normalize action
36
+ normalize_action(action, error_context)
37
+ end
38
+
39
+ private
40
+
41
+ # Build error context object
42
+ def build_error_context(error, message, context, attempt_number)
43
+ Core::ErrorContext.new(
44
+ error: error,
45
+ message: message,
46
+ context: context,
47
+ attempt_number: attempt_number,
48
+ max_attempts: config.max_deliver
49
+ )
50
+ end
51
+
52
+ # Call subscriber's custom error handler
53
+ def call_subscriber_error_handler(error_context)
54
+ subscriber.on_error(error_context)
55
+ rescue StandardError => e
56
+ # If custom handler fails, log and use default
57
+ config.logger&.error(
58
+ "Subscriber error handler failed: #{e.class} - #{e.message}",
59
+ subscriber: subscriber.class.name,
60
+ error: e.class.name
61
+ )
62
+ determine_default_action(error_context)
63
+ end
64
+
65
+ # Determine default error action based on error type
66
+ def determine_default_action(error_context)
67
+ error = error_context.error
68
+
69
+ # Malformed messages -> Discard immediately
70
+ return Core::ErrorAction::DISCARD if malformed_error?(error)
71
+
72
+ # Unrecoverable errors -> DLQ immediately
73
+ return Core::ErrorAction::DLQ if unrecoverable_error?(error)
74
+
75
+ # Last attempt -> DLQ
76
+ return Core::ErrorAction::DLQ if error_context.last_attempt?
77
+
78
+ # Default -> Retry with backoff
79
+ Core::ErrorAction::RETRY
80
+ end
81
+
82
+ # Check if error indicates malformed message
83
+ def malformed_error?(error)
84
+ Constants::Errors::MALFORMED_ERRORS.any? do |error_name|
85
+ error.class.name.include?(error_name)
86
+ end
87
+ end
88
+
89
+ # Check if error is unrecoverable
90
+ def unrecoverable_error?(error)
91
+ Constants::Errors::UNRECOVERABLE_ERRORS.any? do |error_name|
92
+ error.class.name.include?(error_name)
93
+ end
94
+ end
95
+
96
+ # Normalize action to valid ErrorAction constant
97
+ def normalize_action(action, error_context)
98
+ return action if Core::ErrorAction.valid?(action)
99
+
100
+ config.logger&.warn(
101
+ "Invalid error action returned: #{action.inspect}, using default",
102
+ subscriber: subscriber.class.name,
103
+ valid_actions: Core::ErrorAction::ALL
104
+ )
105
+
106
+ determine_default_action(error_context)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Subscribers
5
+ # Graceful shutdown manager for subscribers
6
+ # Handles signal trapping and ensures clean shutdown with message drain
7
+ class GracefulShutdown
8
+ DEFAULT_TIMEOUT = 30 # seconds
9
+
10
+ attr_reader :worker, :timeout, :logger
11
+
12
+ # Initialize graceful shutdown manager
13
+ #
14
+ # @param worker [Worker] Worker instance to manage
15
+ # @param timeout [Integer] Shutdown timeout in seconds
16
+ # @param logger [Logger] Logger instance
17
+ def initialize(worker, timeout: DEFAULT_TIMEOUT, logger: nil)
18
+ @worker = worker
19
+ @timeout = timeout
20
+ @logger = logger || NatsPubsub.config.logger
21
+ @shutting_down = false
22
+ @shutdown_started_at = nil
23
+ end
24
+
25
+ # Start graceful shutdown process
26
+ #
27
+ # @return [Boolean] True if shutdown completed gracefully within timeout
28
+ def shutdown
29
+ return false if @shutting_down
30
+
31
+ @shutting_down = true
32
+ @shutdown_started_at = Time.now
33
+
34
+ logger&.info('Starting graceful shutdown', timeout: timeout)
35
+
36
+ # Stop accepting new messages
37
+ stop_accepting_messages
38
+
39
+ # Wait for in-flight messages
40
+ completed = wait_for_completion
41
+
42
+ # Force terminate if needed
43
+ force_terminate unless completed
44
+
45
+ # Close connections
46
+ close_connections
47
+
48
+ elapsed = (Time.now - @shutdown_started_at).round(1)
49
+ logger&.info('Graceful shutdown complete', elapsed: elapsed, graceful: completed)
50
+
51
+ completed
52
+ end
53
+
54
+ # Check if shutdown is in progress
55
+ #
56
+ # @return [Boolean] True if shutdown is in progress
57
+ def shutting_down?
58
+ @shutting_down
59
+ end
60
+
61
+ # Install signal handlers for graceful shutdown
62
+ # Traps SIGTERM and SIGINT
63
+ def install_signal_handlers
64
+ %w[TERM INT].each do |signal|
65
+ Signal.trap(signal) do
66
+ logger&.info("Received SIG#{signal}, initiating shutdown")
67
+
68
+ begin
69
+ shutdown
70
+ exit(0)
71
+ rescue StandardError => e
72
+ logger&.error('Shutdown failed', error: e.message)
73
+ exit(1)
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # Stop accepting new messages
82
+ def stop_accepting_messages
83
+ logger&.info('Stopping message acceptance')
84
+ worker.pause! if worker.respond_to?(:pause!)
85
+ end
86
+
87
+ # Wait for in-flight messages to complete
88
+ #
89
+ # @return [Boolean] True if all messages completed within timeout
90
+ def wait_for_completion
91
+ deadline = Time.now + timeout
92
+
93
+ loop do
94
+ in_flight = worker.in_flight_count
95
+
96
+ if in_flight.zero?
97
+ logger&.info('All messages processed')
98
+ return true
99
+ end
100
+
101
+ if Time.now >= deadline
102
+ logger&.warn('Shutdown timeout reached',
103
+ in_flight: in_flight,
104
+ timeout: timeout)
105
+ return false
106
+ end
107
+
108
+ logger&.debug('Waiting for messages',
109
+ in_flight: in_flight,
110
+ remaining: (deadline - Time.now).round(1))
111
+ sleep 0.5
112
+ end
113
+ end
114
+
115
+ # Force terminate remaining messages
116
+ def force_terminate
117
+ logger&.warn('Force terminating remaining messages')
118
+ worker.force_stop! if worker.respond_to?(:force_stop!)
119
+ end
120
+
121
+ # Close connections
122
+ def close_connections
123
+ logger&.info('Closing connections')
124
+ worker.close if worker.respond_to?(:close)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+
5
+ module NatsPubsub
6
+ module Subscribers
7
+ # Immutable value object for a single NATS message.
8
+ class InboxMessage
9
+ attr_reader :msg, :seq, :deliveries, :stream, :subject, :headers, :body, :raw, :event_id, :now
10
+
11
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
12
+ def self.from_nats(msg)
13
+ meta = (msg.respond_to?(:metadata) && msg.metadata) || nil
14
+ seq = meta.respond_to?(:stream_sequence) ? meta.stream_sequence : nil
15
+ deliveries = meta.respond_to?(:num_delivered) ? meta.num_delivered : nil
16
+ stream = meta.respond_to?(:stream) ? meta.stream : nil
17
+ consumer = meta.respond_to?(:consumer) ? meta.consumer : nil
18
+ subject = msg.subject.to_s
19
+
20
+ headers = {}
21
+ (msg.header || {}).each { |k, v| headers[k.to_s.downcase] = v }
22
+
23
+ raw = msg.data
24
+ body = begin
25
+ Oj.load(raw, mode: :strict)
26
+ rescue Oj::Error
27
+ {}
28
+ end
29
+
30
+ id = (headers['nats-msg-id'] || body['event_id']).to_s.strip
31
+ id = "seq:#{seq}" if id.empty?
32
+
33
+ new(msg, seq, deliveries, stream, subject, headers, body, raw, id, Time.now.utc, consumer)
34
+ end
35
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
36
+
37
+ # rubocop:disable Metrics/ParameterLists
38
+ def initialize(msg, seq, deliveries, stream, subject, headers, body, raw, event_id, now, consumer = nil)
39
+ @msg = msg
40
+ @seq = seq
41
+ @deliveries = deliveries
42
+ @stream = stream
43
+ @subject = subject
44
+ @headers = headers
45
+ @body = body
46
+ @raw = raw
47
+ @event_id = event_id
48
+ @now = now
49
+ @consumer = consumer
50
+ end
51
+ # rubocop:enable Metrics/ParameterLists
52
+
53
+ def body_for_store
54
+ body.empty? ? raw : body
55
+ end
56
+
57
+ def data
58
+ raw
59
+ end
60
+
61
+ def header
62
+ headers
63
+ end
64
+
65
+ def metadata
66
+ @metadata ||= Struct.new(:num_delivered, :sequence, :consumer, :stream)
67
+ .new(deliveries, seq, @consumer, stream)
68
+ end
69
+
70
+ def ack(*, **)
71
+ msg.ack(*, **) if msg.respond_to?(:ack)
72
+ end
73
+
74
+ def nak(*, **)
75
+ msg.nak(*, **) if msg.respond_to?(:nak)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../core/logging'
4
+ require_relative '../../models/model_utils'
5
+ require_relative 'inbox_message'
6
+ require_relative 'inbox_repository'
7
+
8
+ module NatsPubsub
9
+ module Subscribers
10
+ # Orchestrates AR-backed inbox processing.
11
+ class InboxProcessor
12
+ def initialize(message_processor)
13
+ @processor = message_processor
14
+ end
15
+
16
+ # @return [true,false] processed?
17
+ def process(msg)
18
+ klass = ModelUtils.constantize(NatsPubsub.config.inbox_model)
19
+ return process_direct?(msg, klass) unless ModelUtils.ar_class?(klass)
20
+
21
+ msg = InboxMessage.from_nats(msg)
22
+ repo = InboxRepository.new(klass)
23
+ record = repo.find_or_build(msg)
24
+
25
+ if repo.already_processed?(record)
26
+ msg.ack
27
+ return true
28
+ end
29
+
30
+ repo.persist_pre(record, msg)
31
+ @processor.handle_message(msg)
32
+ repo.persist_post(record)
33
+ true
34
+ rescue StandardError => e
35
+ repo.persist_failure(record, e) if defined?(repo) && defined?(record)
36
+ Logging.error("Inbox processing failed: #{e.class}: #{e.message}",
37
+ tag: 'NatsPubsub::Subscribers::InboxProcessor')
38
+ false
39
+ end
40
+
41
+ private
42
+
43
+ def process_direct?(msg, klass)
44
+ unless ModelUtils.ar_class?(klass)
45
+ Logging.warn("Inbox model #{klass} is not an ActiveRecord model; processing directly.",
46
+ tag: 'NatsPubsub::Subscribers::InboxProcessor')
47
+ end
48
+ @processor.handle_message(msg)
49
+ true
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../models/model_utils'
4
+ require_relative '../../core/logging'
5
+
6
+ module NatsPubsub
7
+ module Subscribers
8
+ # AR-facing operations for inbox rows (find/build/persist).
9
+ class InboxRepository
10
+ def initialize(klass)
11
+ @klass = klass
12
+ end
13
+
14
+ def find_or_build(msg)
15
+ if ModelUtils.has_columns?(@klass, :event_id)
16
+ @klass.find_or_initialize_by(event_id: msg.event_id)
17
+ elsif ModelUtils.has_columns?(@klass, :stream_seq)
18
+ @klass.find_or_initialize_by(stream_seq: msg.seq)
19
+ else
20
+ @klass.new
21
+ end
22
+ end
23
+
24
+ def already_processed?(record)
25
+ record.respond_to?(:processed_at) && record.processed_at
26
+ end
27
+
28
+ def persist_pre(record, msg)
29
+ attrs = {
30
+ event_id: msg.event_id,
31
+ subject: msg.subject,
32
+ payload: ModelUtils.json_dump(msg.body_for_store),
33
+ headers: ModelUtils.json_dump(msg.headers),
34
+ stream: msg.stream,
35
+ stream_seq: msg.seq,
36
+ deliveries: msg.deliveries,
37
+ status: 'processing',
38
+ last_error: nil,
39
+ received_at: record.respond_to?(:received_at) ? (record.received_at || msg.now) : nil,
40
+ updated_at: record.respond_to?(:updated_at) ? msg.now : nil
41
+ }
42
+ ModelUtils.assign_known_attrs(record, attrs)
43
+ record.save!
44
+ end
45
+
46
+ def persist_post(record)
47
+ now = Time.now.utc
48
+ attrs = {
49
+ status: 'processed',
50
+ processed_at: record.respond_to?(:processed_at) ? now : nil,
51
+ updated_at: record.respond_to?(:updated_at) ? now : nil
52
+ }
53
+ ModelUtils.assign_known_attrs(record, attrs)
54
+ record.save!
55
+ end
56
+
57
+ def persist_failure(record, error)
58
+ return unless record
59
+
60
+ now = Time.now.utc
61
+ attrs = {
62
+ status: 'failed',
63
+ last_error: "#{error.class}: #{error.message}",
64
+ updated_at: record.respond_to?(:updated_at) ? now : nil
65
+ }
66
+ ModelUtils.assign_known_attrs(record, attrs)
67
+ record.save!
68
+ rescue StandardError => e
69
+ Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
70
+ tag: 'NatsPubsub::Subscribers::InboxRepository')
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module NatsPubsub
6
+ module Subscribers
7
+ # Immutable value object representing per-message metadata.
8
+ # Extracted from MessageProcessor to follow Single Responsibility Principle.
9
+ #
10
+ # This class encapsulates all metadata associated with a NATS JetStream message,
11
+ # providing a clean interface for accessing message context throughout the
12
+ # processing pipeline.
13
+ #
14
+ # @example Building context from a NATS message
15
+ # context = MessageContext.build(nats_msg)
16
+ # puts "Event ID: #{context.event_id}"
17
+ # puts "Deliveries: #{context.deliveries}"
18
+ #
19
+ # @attr_reader event_id [String] Unique event identifier from message header or generated UUID
20
+ # @attr_reader deliveries [Integer] Number of times this message has been delivered
21
+ # @attr_reader subject [String] NATS subject the message was published to
22
+ # @attr_reader seq [Integer, nil] JetStream sequence number
23
+ # @attr_reader consumer [String, nil] JetStream consumer name
24
+ # @attr_reader stream [String, nil] JetStream stream name
25
+ class MessageContext
26
+ attr_reader :event_id, :deliveries, :subject, :seq, :consumer, :stream
27
+
28
+ # Initialize a new MessageContext
29
+ #
30
+ # @param event_id [String] Unique event identifier
31
+ # @param deliveries [Integer] Number of delivery attempts
32
+ # @param subject [String] NATS subject
33
+ # @param seq [Integer, nil] JetStream sequence number
34
+ # @param consumer [String, nil] JetStream consumer name
35
+ # @param stream [String, nil] JetStream stream name
36
+ def initialize(event_id:, deliveries:, subject:, seq: nil, consumer: nil, stream: nil)
37
+ @event_id = event_id
38
+ @deliveries = deliveries
39
+ @subject = subject
40
+ @seq = seq
41
+ @consumer = consumer
42
+ @stream = stream
43
+ freeze # Make immutable
44
+ end
45
+
46
+ # Build MessageContext from a NATS JetStream message
47
+ #
48
+ # Extracts metadata from the NATS message headers and metadata,
49
+ # falling back to sensible defaults when information is unavailable.
50
+ #
51
+ # @param msg [NATS::Msg] NATS JetStream message object
52
+ # @return [MessageContext] Immutable message context
53
+ def self.build(msg)
54
+ new(
55
+ event_id: msg.header&.[]('nats-msg-id') || SecureRandom.uuid,
56
+ deliveries: msg.metadata&.num_delivered.to_i,
57
+ subject: msg.subject,
58
+ seq: msg.metadata&.sequence,
59
+ consumer: msg.metadata&.consumer,
60
+ stream: msg.metadata&.stream
61
+ )
62
+ end
63
+
64
+ # String representation for debugging
65
+ #
66
+ # @return [String] Human-readable context information
67
+ def to_s
68
+ "MessageContext(event_id=#{event_id}, subject=#{subject}, seq=#{seq}, deliveries=#{deliveries})"
69
+ end
70
+
71
+ # Hash representation for logging
72
+ #
73
+ # @return [Hash] Context as hash
74
+ def to_h
75
+ {
76
+ event_id: event_id,
77
+ deliveries: deliveries,
78
+ subject: subject,
79
+ seq: seq,
80
+ consumer: consumer,
81
+ stream: stream
82
+ }
83
+ end
84
+ end
85
+ end
86
+ end