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