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,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nats/io/client'
|
|
4
|
+
require 'singleton'
|
|
5
|
+
require 'oj'
|
|
6
|
+
require_relative 'duration'
|
|
7
|
+
require_relative 'logging'
|
|
8
|
+
require_relative 'config'
|
|
9
|
+
require_relative '../topology/topology'
|
|
10
|
+
|
|
11
|
+
module NatsPubsub
|
|
12
|
+
# Singleton connection to NATS.
|
|
13
|
+
class Connection
|
|
14
|
+
include Singleton
|
|
15
|
+
|
|
16
|
+
DEFAULT_CONN_OPTS = {
|
|
17
|
+
reconnect: true,
|
|
18
|
+
reconnect_time_wait: 2,
|
|
19
|
+
max_reconnect_attempts: 10,
|
|
20
|
+
connect_timeout: 5
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Thread-safe delegator to the singleton instance.
|
|
25
|
+
# Returns a live JetStream context.
|
|
26
|
+
def connect!
|
|
27
|
+
@__mutex ||= Mutex.new
|
|
28
|
+
@__mutex.synchronize { instance.connect! }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Optional accessors if callers need raw handles
|
|
32
|
+
def nc
|
|
33
|
+
instance.__send__(:nc)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def jetstream
|
|
37
|
+
instance.__send__(:jetstream)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Idempotent: returns an existing, healthy JetStream context or establishes one.
|
|
42
|
+
# NOTE: This method only establishes the connection. Topology setup is separate.
|
|
43
|
+
# Call NatsPubsub.ensure_topology! explicitly after connection if needed.
|
|
44
|
+
def connect!
|
|
45
|
+
return @jts if connected?
|
|
46
|
+
|
|
47
|
+
servers = nats_servers
|
|
48
|
+
raise ConfigurationError, 'No NATS URLs configured' if servers.empty?
|
|
49
|
+
|
|
50
|
+
establish_connection(servers)
|
|
51
|
+
|
|
52
|
+
Logging.info(
|
|
53
|
+
"Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
|
|
54
|
+
"#{sanitize_urls(servers).join(', ')}",
|
|
55
|
+
tag: 'NatsPubsub::Connection'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@jts
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def connected?
|
|
64
|
+
@nc&.connected?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def nats_servers
|
|
68
|
+
NatsPubsub.config.nats_urls
|
|
69
|
+
.to_s
|
|
70
|
+
.split(',')
|
|
71
|
+
.map(&:strip)
|
|
72
|
+
.reject(&:empty?)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def establish_connection(servers)
|
|
76
|
+
@nc = NATS::IO::Client.new
|
|
77
|
+
@nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS))
|
|
78
|
+
|
|
79
|
+
# Create JetStream context
|
|
80
|
+
@jts = @nc.jetstream
|
|
81
|
+
|
|
82
|
+
# --- Compatibility shim: ensure JetStream responds to #nc for older/newer clients ---
|
|
83
|
+
return if @jts.respond_to?(:nc)
|
|
84
|
+
|
|
85
|
+
nc_ref = @nc
|
|
86
|
+
@jts.define_singleton_method(:nc) { nc_ref }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Expose for class-level helpers (not part of public API)
|
|
90
|
+
attr_reader :nc
|
|
91
|
+
|
|
92
|
+
def jetstream
|
|
93
|
+
@jts
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Mask credentials in NATS URLs:
|
|
97
|
+
# - "nats://user:pass@host:4222" -> "nats://user:***@host:4222"
|
|
98
|
+
# - "nats://token@host:4222" -> "nats://***@host:4222"
|
|
99
|
+
def sanitize_urls(urls)
|
|
100
|
+
urls.map { |u| Logging.sanitize_url(u) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
# Centralized constants for the library
|
|
5
|
+
# Extracts magic numbers and strings into named, documented constants
|
|
6
|
+
module Constants
|
|
7
|
+
# Timeout-related constants
|
|
8
|
+
module Timeouts
|
|
9
|
+
# Default acknowledgment wait time in milliseconds
|
|
10
|
+
# Time before a message is considered unacknowledged and redelivered
|
|
11
|
+
ACK_WAIT_DEFAULT = 30_000 # 30 seconds
|
|
12
|
+
|
|
13
|
+
# Default idle wait time in milliseconds
|
|
14
|
+
# Time to wait when no messages are available before polling again
|
|
15
|
+
IDLE_WAIT_DEFAULT = 100 # 100ms
|
|
16
|
+
|
|
17
|
+
# Default connection timeout in seconds
|
|
18
|
+
CONNECTION_TIMEOUT = 5
|
|
19
|
+
|
|
20
|
+
# Default request timeout for management operations
|
|
21
|
+
MANAGEMENT_TIMEOUT = 10
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Retry strategy constants
|
|
25
|
+
module Retry
|
|
26
|
+
# Default exponential backoff delays in milliseconds
|
|
27
|
+
# Used for retrying failed message processing
|
|
28
|
+
DEFAULT_BACKOFF = [1_000, 5_000, 15_000, 30_000, 60_000].freeze
|
|
29
|
+
|
|
30
|
+
# Maximum number of delivery attempts before sending to DLQ
|
|
31
|
+
MAX_ATTEMPTS = 5
|
|
32
|
+
|
|
33
|
+
# Base delay for transient errors (milliseconds)
|
|
34
|
+
TRANSIENT_BASE_DELAY = 500
|
|
35
|
+
|
|
36
|
+
# Base delay for persistent errors (milliseconds)
|
|
37
|
+
PERSISTENT_BASE_DELAY = 2_000
|
|
38
|
+
|
|
39
|
+
# Maximum backoff delay cap (milliseconds)
|
|
40
|
+
MAX_BACKOFF_CAP = 60_000 # 60 seconds
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Dead Letter Queue constants
|
|
44
|
+
module DLQ
|
|
45
|
+
# Default retention period for DLQ messages (30 days in nanoseconds)
|
|
46
|
+
RETENTION_PERIOD = 30 * 24 * 60 * 60 * 1_000_000_000
|
|
47
|
+
|
|
48
|
+
# DLQ stream suffix
|
|
49
|
+
STREAM_SUFFIX = '-dlq'
|
|
50
|
+
|
|
51
|
+
# Maximum attempts before moving to DLQ
|
|
52
|
+
MAX_ATTEMPTS = 3
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Stream and consumer configuration constants
|
|
56
|
+
module Stream
|
|
57
|
+
# Default stream retention policy
|
|
58
|
+
RETENTION_POLICY = 'limits' # limits, interest, workqueue
|
|
59
|
+
|
|
60
|
+
# Default storage type
|
|
61
|
+
STORAGE_TYPE = 'file' # file or memory
|
|
62
|
+
|
|
63
|
+
# Default max message age (7 days in nanoseconds)
|
|
64
|
+
MAX_AGE = 7 * 24 * 60 * 60 * 1_000_000_000
|
|
65
|
+
|
|
66
|
+
# Default max messages per stream
|
|
67
|
+
MAX_MESSAGES = 1_000_000
|
|
68
|
+
|
|
69
|
+
# Default max bytes per stream (1GB)
|
|
70
|
+
MAX_BYTES = 1_073_741_824
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Consumer configuration constants
|
|
74
|
+
module Consumer
|
|
75
|
+
# Default concurrency level
|
|
76
|
+
DEFAULT_CONCURRENCY = 5
|
|
77
|
+
|
|
78
|
+
# Minimum concurrency
|
|
79
|
+
MIN_CONCURRENCY = 1
|
|
80
|
+
|
|
81
|
+
# Maximum concurrency (prevents resource exhaustion)
|
|
82
|
+
MAX_CONCURRENCY = 1000
|
|
83
|
+
|
|
84
|
+
# Default batch size for pull consumers
|
|
85
|
+
BATCH_SIZE = 10
|
|
86
|
+
|
|
87
|
+
# Maximum batch size
|
|
88
|
+
MAX_BATCH_SIZE = 256
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Subject pattern constants
|
|
92
|
+
module Subject
|
|
93
|
+
# Wildcard for matching one level
|
|
94
|
+
SINGLE_LEVEL_WILDCARD = '*'
|
|
95
|
+
|
|
96
|
+
# Wildcard for matching multiple levels
|
|
97
|
+
MULTI_LEVEL_WILDCARD = '>'
|
|
98
|
+
|
|
99
|
+
# Subject token separator
|
|
100
|
+
SEPARATOR = '.'
|
|
101
|
+
|
|
102
|
+
# Maximum subject length
|
|
103
|
+
MAX_LENGTH = 255
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Envelope schema constants
|
|
107
|
+
module Envelope
|
|
108
|
+
# Current schema version
|
|
109
|
+
SCHEMA_VERSION = 1
|
|
110
|
+
|
|
111
|
+
# Required envelope fields
|
|
112
|
+
REQUIRED_FIELDS = %w[
|
|
113
|
+
event_id
|
|
114
|
+
schema_version
|
|
115
|
+
producer
|
|
116
|
+
occurred_at
|
|
117
|
+
].freeze
|
|
118
|
+
|
|
119
|
+
# Topic envelope required fields
|
|
120
|
+
TOPIC_REQUIRED_FIELDS = (REQUIRED_FIELDS + %w[topic message]).freeze
|
|
121
|
+
|
|
122
|
+
# Event envelope required fields (legacy)
|
|
123
|
+
EVENT_REQUIRED_FIELDS = (REQUIRED_FIELDS + %w[domain resource action payload]).freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Error classification constants
|
|
127
|
+
module Errors
|
|
128
|
+
# Errors that should not be retried
|
|
129
|
+
UNRECOVERABLE_ERRORS = [
|
|
130
|
+
'ArgumentError',
|
|
131
|
+
'TypeError',
|
|
132
|
+
'NoMethodError',
|
|
133
|
+
'NameError',
|
|
134
|
+
'SyntaxError'
|
|
135
|
+
].freeze
|
|
136
|
+
|
|
137
|
+
# Errors indicating malformed messages
|
|
138
|
+
MALFORMED_ERRORS = [
|
|
139
|
+
'JSON::ParserError',
|
|
140
|
+
'Oj::ParseError',
|
|
141
|
+
'EncodingError'
|
|
142
|
+
].freeze
|
|
143
|
+
|
|
144
|
+
# Transient errors that should be retried
|
|
145
|
+
TRANSIENT_ERRORS = [
|
|
146
|
+
'Timeout::Error',
|
|
147
|
+
'IOError',
|
|
148
|
+
'Errno::ECONNREFUSED',
|
|
149
|
+
'Errno::ETIMEDOUT',
|
|
150
|
+
'NATS::IO::Timeout',
|
|
151
|
+
'NATS::IO::Error'
|
|
152
|
+
].freeze
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Logging constants
|
|
156
|
+
module Logging
|
|
157
|
+
# Default log level
|
|
158
|
+
DEFAULT_LEVEL = :info
|
|
159
|
+
|
|
160
|
+
# Log levels
|
|
161
|
+
LEVELS = %i[debug info warn error fatal].freeze
|
|
162
|
+
|
|
163
|
+
# Structured log field names
|
|
164
|
+
FIELDS = {
|
|
165
|
+
event_id: 'event_id',
|
|
166
|
+
trace_id: 'trace_id',
|
|
167
|
+
subject: 'subject',
|
|
168
|
+
topic: 'topic',
|
|
169
|
+
delivery_count: 'delivery_count',
|
|
170
|
+
elapsed_ms: 'elapsed_ms',
|
|
171
|
+
error_class: 'error_class',
|
|
172
|
+
error_message: 'error_message'
|
|
173
|
+
}.freeze
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Health check constants
|
|
177
|
+
module HealthCheck
|
|
178
|
+
# Quick check timeout (seconds)
|
|
179
|
+
QUICK_TIMEOUT = 5
|
|
180
|
+
|
|
181
|
+
# Full check timeout (seconds)
|
|
182
|
+
FULL_TIMEOUT = 30
|
|
183
|
+
|
|
184
|
+
# Health check statuses
|
|
185
|
+
HEALTHY = 'healthy'
|
|
186
|
+
DEGRADED = 'degraded'
|
|
187
|
+
UNHEALTHY = 'unhealthy'
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# NatsPubsub
|
|
4
|
+
#
|
|
5
|
+
module NatsPubsub
|
|
6
|
+
# Utility for parsing human-friendly durations into milliseconds.
|
|
7
|
+
#
|
|
8
|
+
# Defaults to an :auto heuristic for Integer/Float values to preserve
|
|
9
|
+
# backward compatibility:
|
|
10
|
+
# - Integers < 1000 are treated as seconds (e.g., 30 -> 30_000ms)
|
|
11
|
+
# - Integers >= 1000 are treated as milliseconds (e.g., 1500 -> 1500ms)
|
|
12
|
+
# Prefer setting `default_unit:` to :s or :ms for unambiguous behavior.
|
|
13
|
+
#
|
|
14
|
+
# Examples:
|
|
15
|
+
# Duration.to_millis(30) #=> 30000 (auto)
|
|
16
|
+
# Duration.to_millis(1500) #=> 1500 (auto)
|
|
17
|
+
# Duration.to_millis("1500") #=> 1500 (auto)
|
|
18
|
+
# Duration.to_millis(1500, default_unit: :s) #=> 1_500_000
|
|
19
|
+
# Duration.to_millis("30s") #=> 30000
|
|
20
|
+
# Duration.to_millis("500ms") #=> 500
|
|
21
|
+
# Duration.to_millis("250us") #=> 0
|
|
22
|
+
# Duration.to_millis("1h") #=> 3_600_000
|
|
23
|
+
# Duration.to_millis(1_500_000_000, default_unit: :ns) #=> 1500
|
|
24
|
+
#
|
|
25
|
+
# Also:
|
|
26
|
+
# Duration.normalize_list_to_millis(%w[1s 5s 15s]) #=> [1000, 5000, 15000]
|
|
27
|
+
module Duration
|
|
28
|
+
# multipliers to convert 1 unit into milliseconds
|
|
29
|
+
MULTIPLIER_MS = {
|
|
30
|
+
'ns' => 1.0e-6, # nanoseconds to ms
|
|
31
|
+
'us' => 1.0e-3, # microseconds to ms
|
|
32
|
+
'µs' => 1.0e-3, # alt microseconds symbol
|
|
33
|
+
'ms' => 1, # milliseconds to ms
|
|
34
|
+
's' => 1_000, # seconds to ms
|
|
35
|
+
'm' => 60_000, # minutes to ms
|
|
36
|
+
'h' => 3_600_000, # hours to ms
|
|
37
|
+
'd' => 86_400_000 # days to ms
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
NUMBER_RE = /\A\d[\d_]*\z/
|
|
41
|
+
TOKEN_RE = /\A(\d[\d_]*(?:\.\d+)?)\s*(ns|us|µs|ms|s|m|h|d)\z/i
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# default_unit:
|
|
46
|
+
# :auto (heuristic: int<1000 -> seconds, >=1000 -> ms)
|
|
47
|
+
# :ns, :us, :ms, :s, :m, :h, :d (explicit)
|
|
48
|
+
def to_millis(val, default_unit: :auto)
|
|
49
|
+
case val
|
|
50
|
+
when Integer then int_to_ms(val, default_unit: default_unit)
|
|
51
|
+
when Float then float_to_ms(val, default_unit: default_unit)
|
|
52
|
+
when String then string_to_ms(val, default_unit: default_unit)
|
|
53
|
+
else
|
|
54
|
+
raise ArgumentError, "invalid duration type: #{val.class}" unless val.respond_to?(:to_f)
|
|
55
|
+
|
|
56
|
+
float_to_ms(val.to_f, default_unit: default_unit)
|
|
57
|
+
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Normalize an array of durations into integer milliseconds.
|
|
62
|
+
def normalize_list_to_millis(values, default_unit: :auto)
|
|
63
|
+
vals = Array(values)
|
|
64
|
+
return [] if vals.empty?
|
|
65
|
+
|
|
66
|
+
vals.map { |v| to_millis(v, default_unit: default_unit) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# --- internal helpers ---
|
|
70
|
+
|
|
71
|
+
def int_to_ms(num, default_unit:)
|
|
72
|
+
case default_unit
|
|
73
|
+
when :auto
|
|
74
|
+
# Preserve existing heuristic for compatibility
|
|
75
|
+
num >= 1_000 ? num : num * 1_000
|
|
76
|
+
else
|
|
77
|
+
coerce_numeric_to_ms(num.to_f, default_unit)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def float_to_ms(flt, default_unit:)
|
|
82
|
+
coerce_numeric_to_ms(flt, default_unit)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def string_to_ms(str, default_unit:)
|
|
86
|
+
s = str.strip
|
|
87
|
+
# Plain number strings are treated like integers so the :auto
|
|
88
|
+
# heuristic still applies (<1000 => seconds, >=1000 => ms).
|
|
89
|
+
return int_to_ms(s.delete('_').to_i, default_unit: default_unit) if NUMBER_RE.match?(s)
|
|
90
|
+
|
|
91
|
+
m = TOKEN_RE.match(s)
|
|
92
|
+
raise ArgumentError, "invalid duration: #{str.inspect}" unless m
|
|
93
|
+
|
|
94
|
+
num = m[1].delete('_').to_f
|
|
95
|
+
unit = m[2].downcase
|
|
96
|
+
(num * MULTIPLIER_MS.fetch(unit)).round
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def coerce_numeric_to_ms(num, unit)
|
|
100
|
+
case unit
|
|
101
|
+
when :auto
|
|
102
|
+
# For floats, :auto treats as seconds (common developer intent)
|
|
103
|
+
(num * 1_000).round
|
|
104
|
+
else
|
|
105
|
+
u = unit.to_s
|
|
106
|
+
mult = MULTIPLIER_MS[u]
|
|
107
|
+
raise ArgumentError, "invalid unit for default_unit: #{unit.inspect}" unless mult
|
|
108
|
+
|
|
109
|
+
(num * mult).round
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
module Core
|
|
5
|
+
# Error action constants for fine-grained error handling control
|
|
6
|
+
#
|
|
7
|
+
# @example Using in a subscriber
|
|
8
|
+
# class PaymentSubscriber < NatsPubsub::Subscriber
|
|
9
|
+
# subscribe_to 'payment.process'
|
|
10
|
+
#
|
|
11
|
+
# def handle(message, context)
|
|
12
|
+
# process_payment(message)
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# def on_error(error, message, context)
|
|
16
|
+
# case error
|
|
17
|
+
# when ValidationError
|
|
18
|
+
# ErrorAction::DISCARD
|
|
19
|
+
# when NetworkError, Timeout::Error
|
|
20
|
+
# ErrorAction::RETRY
|
|
21
|
+
# else
|
|
22
|
+
# ErrorAction::DLQ
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
module ErrorAction
|
|
28
|
+
# Retry the message with backoff strategy
|
|
29
|
+
RETRY = :retry
|
|
30
|
+
|
|
31
|
+
# Acknowledge and discard the message (no retry)
|
|
32
|
+
DISCARD = :discard
|
|
33
|
+
|
|
34
|
+
# Send message to dead letter queue
|
|
35
|
+
DLQ = :dlq
|
|
36
|
+
|
|
37
|
+
# All valid actions
|
|
38
|
+
ALL = [RETRY, DISCARD, DLQ].freeze
|
|
39
|
+
|
|
40
|
+
# Check if action is valid
|
|
41
|
+
#
|
|
42
|
+
# @param action [Symbol] Action to validate
|
|
43
|
+
# @return [Boolean] True if valid
|
|
44
|
+
def self.valid?(action)
|
|
45
|
+
ALL.include?(action)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Get default action
|
|
49
|
+
#
|
|
50
|
+
# @return [Symbol] Default action (:retry)
|
|
51
|
+
def self.default
|
|
52
|
+
RETRY
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Error context passed to error handlers
|
|
57
|
+
#
|
|
58
|
+
# @!attribute [r] error
|
|
59
|
+
# @return [Exception] The error that occurred
|
|
60
|
+
# @!attribute [r] message
|
|
61
|
+
# @return [Hash] The message that failed
|
|
62
|
+
# @!attribute [r] context
|
|
63
|
+
# @return [MessageContext] Message context
|
|
64
|
+
# @!attribute [r] attempt_number
|
|
65
|
+
# @return [Integer] Current attempt number (1-based)
|
|
66
|
+
# @!attribute [r] max_attempts
|
|
67
|
+
# @return [Integer] Maximum delivery attempts configured
|
|
68
|
+
#
|
|
69
|
+
class ErrorContext
|
|
70
|
+
attr_reader :error, :message, :context, :attempt_number, :max_attempts
|
|
71
|
+
|
|
72
|
+
# Initialize a new error context
|
|
73
|
+
#
|
|
74
|
+
# @param error [Exception] The error
|
|
75
|
+
# @param message [Hash] The message
|
|
76
|
+
# @param context [MessageContext] Message context
|
|
77
|
+
# @param attempt_number [Integer] Attempt number
|
|
78
|
+
# @param max_attempts [Integer] Max attempts
|
|
79
|
+
def initialize(error:, message:, context:, attempt_number:, max_attempts:)
|
|
80
|
+
@error = error
|
|
81
|
+
@message = message
|
|
82
|
+
@context = context
|
|
83
|
+
@attempt_number = attempt_number
|
|
84
|
+
@max_attempts = max_attempts
|
|
85
|
+
|
|
86
|
+
freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check if this is the last attempt
|
|
90
|
+
#
|
|
91
|
+
# @return [Boolean] True if last attempt
|
|
92
|
+
def last_attempt?
|
|
93
|
+
attempt_number >= max_attempts
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Check if retries are exhausted
|
|
97
|
+
#
|
|
98
|
+
# @return [Boolean] True if exhausted
|
|
99
|
+
def retries_exhausted?
|
|
100
|
+
last_attempt?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get remaining attempts
|
|
104
|
+
#
|
|
105
|
+
# @return [Integer] Number of remaining attempts
|
|
106
|
+
def remaining_attempts
|
|
107
|
+
[max_attempts - attempt_number, 0].max
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Convert to hash
|
|
111
|
+
#
|
|
112
|
+
# @return [Hash] Hash representation
|
|
113
|
+
def to_h
|
|
114
|
+
{
|
|
115
|
+
error: error.class.name,
|
|
116
|
+
error_message: error.message,
|
|
117
|
+
message: message,
|
|
118
|
+
context: context.to_h,
|
|
119
|
+
attempt_number: attempt_number,
|
|
120
|
+
max_attempts: max_attempts,
|
|
121
|
+
last_attempt: last_attempt?,
|
|
122
|
+
remaining_attempts: remaining_attempts
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
alias to_hash to_h
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Retry strategy configuration
|
|
130
|
+
#
|
|
131
|
+
# @!attribute [r] max_attempts
|
|
132
|
+
# @return [Integer] Maximum number of retry attempts
|
|
133
|
+
# @!attribute [r] backoff
|
|
134
|
+
# @return [Symbol] Backoff strategy (:exponential, :linear, :fixed)
|
|
135
|
+
# @!attribute [r] initial_delay
|
|
136
|
+
# @return [Integer] Initial delay in milliseconds
|
|
137
|
+
# @!attribute [r] max_delay
|
|
138
|
+
# @return [Integer] Maximum delay in milliseconds
|
|
139
|
+
# @!attribute [r] multiplier
|
|
140
|
+
# @return [Float] Multiplier for exponential backoff
|
|
141
|
+
#
|
|
142
|
+
class RetryStrategy
|
|
143
|
+
attr_reader :max_attempts, :backoff, :initial_delay, :max_delay, :multiplier
|
|
144
|
+
|
|
145
|
+
# Default values
|
|
146
|
+
DEFAULT_MAX_ATTEMPTS = 5
|
|
147
|
+
DEFAULT_BACKOFF = :exponential
|
|
148
|
+
DEFAULT_INITIAL_DELAY = 1_000 # 1 second
|
|
149
|
+
DEFAULT_MAX_DELAY = 60_000 # 60 seconds
|
|
150
|
+
DEFAULT_MULTIPLIER = 2.0
|
|
151
|
+
|
|
152
|
+
# Initialize a new retry strategy
|
|
153
|
+
#
|
|
154
|
+
# @param max_attempts [Integer] Maximum attempts
|
|
155
|
+
# @param backoff [Symbol] Backoff strategy
|
|
156
|
+
# @param initial_delay [Integer] Initial delay in ms
|
|
157
|
+
# @param max_delay [Integer] Max delay in ms
|
|
158
|
+
# @param multiplier [Float] Backoff multiplier
|
|
159
|
+
def initialize(
|
|
160
|
+
max_attempts: DEFAULT_MAX_ATTEMPTS,
|
|
161
|
+
backoff: DEFAULT_BACKOFF,
|
|
162
|
+
initial_delay: DEFAULT_INITIAL_DELAY,
|
|
163
|
+
max_delay: DEFAULT_MAX_DELAY,
|
|
164
|
+
multiplier: DEFAULT_MULTIPLIER
|
|
165
|
+
)
|
|
166
|
+
@max_attempts = max_attempts
|
|
167
|
+
@backoff = backoff
|
|
168
|
+
@initial_delay = initial_delay
|
|
169
|
+
@max_delay = max_delay
|
|
170
|
+
@multiplier = multiplier
|
|
171
|
+
|
|
172
|
+
validate!
|
|
173
|
+
freeze
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Calculate delay for an attempt
|
|
177
|
+
#
|
|
178
|
+
# @param attempt [Integer] Attempt number (1-based)
|
|
179
|
+
# @return [Integer] Delay in milliseconds
|
|
180
|
+
def delay_for_attempt(attempt)
|
|
181
|
+
delay = case backoff
|
|
182
|
+
when :exponential
|
|
183
|
+
exponential_delay(attempt)
|
|
184
|
+
when :linear
|
|
185
|
+
linear_delay(attempt)
|
|
186
|
+
when :fixed
|
|
187
|
+
initial_delay
|
|
188
|
+
else
|
|
189
|
+
initial_delay
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
[delay, max_delay].min
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Convert to hash
|
|
196
|
+
#
|
|
197
|
+
# @return [Hash] Hash representation
|
|
198
|
+
def to_h
|
|
199
|
+
{
|
|
200
|
+
max_attempts: max_attempts,
|
|
201
|
+
backoff: backoff,
|
|
202
|
+
initial_delay: initial_delay,
|
|
203
|
+
max_delay: max_delay,
|
|
204
|
+
multiplier: multiplier
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
alias to_hash to_h
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
# Calculate exponential delay
|
|
213
|
+
#
|
|
214
|
+
# @param attempt [Integer] Attempt number
|
|
215
|
+
# @return [Integer] Delay in milliseconds
|
|
216
|
+
def exponential_delay(attempt)
|
|
217
|
+
(initial_delay * (multiplier**(attempt - 1))).to_i
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Calculate linear delay
|
|
221
|
+
#
|
|
222
|
+
# @param attempt [Integer] Attempt number
|
|
223
|
+
# @return [Integer] Delay in milliseconds
|
|
224
|
+
def linear_delay(attempt)
|
|
225
|
+
initial_delay * attempt
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Validate configuration
|
|
229
|
+
#
|
|
230
|
+
# @raise [ArgumentError] If configuration is invalid
|
|
231
|
+
def validate!
|
|
232
|
+
raise ArgumentError, 'max_attempts must be positive' unless max_attempts.positive?
|
|
233
|
+
raise ArgumentError, 'initial_delay must be positive' unless initial_delay.positive?
|
|
234
|
+
raise ArgumentError, 'max_delay must be positive' unless max_delay.positive?
|
|
235
|
+
raise ArgumentError, 'multiplier must be positive' unless multiplier.positive?
|
|
236
|
+
|
|
237
|
+
valid_backoffs = %i[exponential linear fixed]
|
|
238
|
+
return if valid_backoffs.include?(backoff)
|
|
239
|
+
|
|
240
|
+
raise ArgumentError, "backoff must be one of: #{valid_backoffs.join(', ')}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Circuit breaker configuration
|
|
245
|
+
#
|
|
246
|
+
# @!attribute [r] enabled
|
|
247
|
+
# @return [Boolean] Enable circuit breaker
|
|
248
|
+
# @!attribute [r] threshold
|
|
249
|
+
# @return [Integer] Number of failures before opening
|
|
250
|
+
# @!attribute [r] timeout
|
|
251
|
+
# @return [Integer] Time to keep circuit open in milliseconds
|
|
252
|
+
# @!attribute [r] half_open_max_calls
|
|
253
|
+
# @return [Integer] Number of test calls in half-open state
|
|
254
|
+
#
|
|
255
|
+
class CircuitBreakerConfig
|
|
256
|
+
attr_reader :enabled, :threshold, :timeout, :half_open_max_calls
|
|
257
|
+
|
|
258
|
+
# Initialize circuit breaker config
|
|
259
|
+
#
|
|
260
|
+
# @param enabled [Boolean] Enable circuit breaker
|
|
261
|
+
# @param threshold [Integer] Failure threshold
|
|
262
|
+
# @param timeout [Integer] Timeout in milliseconds
|
|
263
|
+
# @param half_open_max_calls [Integer] Max calls in half-open state
|
|
264
|
+
def initialize(enabled: false, threshold: 5, timeout: 60_000, half_open_max_calls: 3)
|
|
265
|
+
@enabled = enabled
|
|
266
|
+
@threshold = threshold
|
|
267
|
+
@timeout = timeout
|
|
268
|
+
@half_open_max_calls = half_open_max_calls
|
|
269
|
+
|
|
270
|
+
freeze
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Convert to hash
|
|
274
|
+
#
|
|
275
|
+
# @return [Hash] Hash representation
|
|
276
|
+
def to_h
|
|
277
|
+
{
|
|
278
|
+
enabled: enabled,
|
|
279
|
+
threshold: threshold,
|
|
280
|
+
timeout: timeout,
|
|
281
|
+
half_open_max_calls: half_open_max_calls
|
|
282
|
+
}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
alias to_hash to_h
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|