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,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
# FluentBatch provides a modern, chainable API for batch publishing
|
|
5
|
+
#
|
|
6
|
+
# @example Basic usage
|
|
7
|
+
# result = NatsPubsub.batch do |b|
|
|
8
|
+
# b.add 'user.created', { id: 1, name: 'Alice' }
|
|
9
|
+
# b.add 'user.created', { id: 2, name: 'Bob' }
|
|
10
|
+
# b.add 'notification.sent', { user_id: 1 }
|
|
11
|
+
# b.with_options trace_id: 'batch-123'
|
|
12
|
+
# end.publish
|
|
13
|
+
#
|
|
14
|
+
# puts "Published #{result.succeeded}/#{result.total} messages"
|
|
15
|
+
#
|
|
16
|
+
class FluentBatch
|
|
17
|
+
# @!attribute [r] items
|
|
18
|
+
# @return [Array<Hash>] The batch items
|
|
19
|
+
attr_reader :items
|
|
20
|
+
|
|
21
|
+
# @!attribute [r] options
|
|
22
|
+
# @return [Hash] Shared options for all messages
|
|
23
|
+
attr_reader :options
|
|
24
|
+
|
|
25
|
+
# Initialize a new batch publisher
|
|
26
|
+
#
|
|
27
|
+
# @param publisher [Publisher] Optional publisher instance
|
|
28
|
+
def initialize(publisher = nil)
|
|
29
|
+
@publisher = publisher || NatsPubsub::Publisher.new
|
|
30
|
+
@items = []
|
|
31
|
+
@options = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Add a message to the batch
|
|
35
|
+
#
|
|
36
|
+
# @param topic [String] Topic to publish to
|
|
37
|
+
# @param message [Hash] Message payload
|
|
38
|
+
# @return [self] For chaining
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# batch.add('user.created', { id: 1, name: 'Alice' })
|
|
42
|
+
def add(topic, message)
|
|
43
|
+
@items << { topic: topic, message: message }
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Set options for all messages in the batch
|
|
48
|
+
#
|
|
49
|
+
# @param options [Hash] Options to apply to all messages
|
|
50
|
+
# @option options [String] :trace_id Distributed tracing ID
|
|
51
|
+
# @option options [String] :correlation_id Request correlation ID
|
|
52
|
+
# @option options [String] :event_id Event ID
|
|
53
|
+
# @option options [Time] :occurred_at Event timestamp
|
|
54
|
+
# @return [self] For chaining
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# batch.with_options(trace_id: 'trace-123', correlation_id: 'req-456')
|
|
58
|
+
def with_options(**options)
|
|
59
|
+
@options.merge!(options)
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Publish all messages in the batch
|
|
64
|
+
#
|
|
65
|
+
# Messages are published in parallel for performance.
|
|
66
|
+
#
|
|
67
|
+
# @return [BatchResult] Result with success/failure details
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# result = batch.publish
|
|
71
|
+
# puts "Succeeded: #{result.succeeded}, Failed: #{result.failed}"
|
|
72
|
+
def publish
|
|
73
|
+
start_time = Time.now
|
|
74
|
+
|
|
75
|
+
return empty_result if @items.empty?
|
|
76
|
+
|
|
77
|
+
logger&.debug "Publishing batch of #{@items.size} messages"
|
|
78
|
+
|
|
79
|
+
# Publish all items in parallel using threads
|
|
80
|
+
results = publish_parallel
|
|
81
|
+
|
|
82
|
+
succeeded = results.count { |r| r[:success] }
|
|
83
|
+
failed = results.count { |r| !r[:success] }
|
|
84
|
+
|
|
85
|
+
duration = ((Time.now - start_time) * 1000).round(2)
|
|
86
|
+
|
|
87
|
+
logger&.info "Batch publish completed: #{succeeded}/#{@items.size} succeeded, #{failed} failed (#{duration}ms)"
|
|
88
|
+
|
|
89
|
+
BatchResult.new(
|
|
90
|
+
total: @items.size,
|
|
91
|
+
succeeded: succeeded,
|
|
92
|
+
failed: failed,
|
|
93
|
+
results: results,
|
|
94
|
+
duration: duration
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Clear all items from the batch
|
|
99
|
+
#
|
|
100
|
+
# @return [self] For chaining
|
|
101
|
+
def clear
|
|
102
|
+
@items = []
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get the number of items in the batch
|
|
107
|
+
#
|
|
108
|
+
# @return [Integer] Number of items
|
|
109
|
+
def size
|
|
110
|
+
@items.size
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
alias count size
|
|
114
|
+
alias length size
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Publish items in parallel using threads
|
|
119
|
+
#
|
|
120
|
+
# @return [Array<Hash>] Array of result hashes
|
|
121
|
+
def publish_parallel
|
|
122
|
+
threads = @items.map.with_index do |item, index|
|
|
123
|
+
Thread.new do
|
|
124
|
+
publish_single_item(item, index)
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
{
|
|
127
|
+
topic: item[:topic],
|
|
128
|
+
success: false,
|
|
129
|
+
error: e.message,
|
|
130
|
+
index: index
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
threads.map(&:value)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Publish a single item
|
|
139
|
+
#
|
|
140
|
+
# @param item [Hash] Item to publish
|
|
141
|
+
# @param index [Integer] Index in the batch
|
|
142
|
+
# @return [Hash] Result hash
|
|
143
|
+
def publish_single_item(item, index)
|
|
144
|
+
@publisher.publish(
|
|
145
|
+
topic: item[:topic],
|
|
146
|
+
message: item[:message],
|
|
147
|
+
**@options
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
topic: item[:topic],
|
|
152
|
+
success: true,
|
|
153
|
+
event_id: @options[:event_id],
|
|
154
|
+
index: index
|
|
155
|
+
}
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
logger&.error "Batch publish failed for topic #{item[:topic]}: #{e.message}"
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
topic: item[:topic],
|
|
161
|
+
success: false,
|
|
162
|
+
error: e.message,
|
|
163
|
+
index: index
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Get logger from config
|
|
168
|
+
#
|
|
169
|
+
# @return [Logger, nil] Logger instance
|
|
170
|
+
def logger
|
|
171
|
+
NatsPubsub.config.logger
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Create empty result
|
|
175
|
+
#
|
|
176
|
+
# @return [BatchResult] Empty result
|
|
177
|
+
def empty_result
|
|
178
|
+
BatchResult.new(
|
|
179
|
+
total: 0,
|
|
180
|
+
succeeded: 0,
|
|
181
|
+
failed: 0,
|
|
182
|
+
results: [],
|
|
183
|
+
duration: 0.0
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Result of a batch publish operation
|
|
189
|
+
#
|
|
190
|
+
# @!attribute [r] total
|
|
191
|
+
# @return [Integer] Total number of messages
|
|
192
|
+
# @!attribute [r] succeeded
|
|
193
|
+
# @return [Integer] Number of successful messages
|
|
194
|
+
# @!attribute [r] failed
|
|
195
|
+
# @return [Integer] Number of failed messages
|
|
196
|
+
# @!attribute [r] results
|
|
197
|
+
# @return [Array<Hash>] Detailed results for each message
|
|
198
|
+
# @!attribute [r] duration
|
|
199
|
+
# @return [Float] Duration in milliseconds
|
|
200
|
+
#
|
|
201
|
+
class BatchResult
|
|
202
|
+
attr_reader :total, :succeeded, :failed, :results, :duration
|
|
203
|
+
|
|
204
|
+
# Initialize a new batch result
|
|
205
|
+
#
|
|
206
|
+
# @param total [Integer] Total number of messages
|
|
207
|
+
# @param succeeded [Integer] Number of successful messages
|
|
208
|
+
# @param failed [Integer] Number of failed messages
|
|
209
|
+
# @param results [Array<Hash>] Detailed results
|
|
210
|
+
# @param duration [Float] Duration in milliseconds
|
|
211
|
+
def initialize(total:, succeeded:, failed:, results:, duration:)
|
|
212
|
+
@total = total
|
|
213
|
+
@succeeded = succeeded
|
|
214
|
+
@failed = failed
|
|
215
|
+
@results = results
|
|
216
|
+
@duration = duration
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Check if all messages succeeded
|
|
220
|
+
#
|
|
221
|
+
# @return [Boolean] True if all succeeded
|
|
222
|
+
def success?
|
|
223
|
+
failed.zero?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Check if any messages failed
|
|
227
|
+
#
|
|
228
|
+
# @return [Boolean] True if any failed
|
|
229
|
+
def failure?
|
|
230
|
+
failed.positive?
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Get failed results
|
|
234
|
+
#
|
|
235
|
+
# @return [Array<Hash>] Failed results
|
|
236
|
+
def failures
|
|
237
|
+
results.reject { |r| r[:success] }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Get successful results
|
|
241
|
+
#
|
|
242
|
+
# @return [Array<Hash>] Successful results
|
|
243
|
+
def successes
|
|
244
|
+
results.select { |r| r[:success] }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Convert to hash
|
|
248
|
+
#
|
|
249
|
+
# @return [Hash] Hash representation
|
|
250
|
+
def to_h
|
|
251
|
+
{
|
|
252
|
+
total: total,
|
|
253
|
+
succeeded: succeeded,
|
|
254
|
+
failed: failed,
|
|
255
|
+
results: results,
|
|
256
|
+
duration: duration
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
alias to_hash to_h
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/logging'
|
|
4
|
+
require_relative '../models/model_utils'
|
|
5
|
+
require_relative 'outbox_repository'
|
|
6
|
+
require_relative 'publish_result'
|
|
7
|
+
|
|
8
|
+
module NatsPubsub
|
|
9
|
+
# Service object responsible for publishing messages through the Outbox pattern
|
|
10
|
+
# Extracts outbox publishing logic from Publisher following Single Responsibility Principle
|
|
11
|
+
#
|
|
12
|
+
# The Outbox pattern ensures reliable message delivery by:
|
|
13
|
+
# 1. Persisting the message to the database before publishing
|
|
14
|
+
# 2. Publishing to NATS
|
|
15
|
+
# 3. Marking as sent in the database
|
|
16
|
+
#
|
|
17
|
+
# This prevents message loss if the application crashes between publishing and commit
|
|
18
|
+
class OutboxPublisher
|
|
19
|
+
# Publish a message using the Outbox pattern
|
|
20
|
+
#
|
|
21
|
+
# @param subject [String] NATS subject
|
|
22
|
+
# @param envelope [Hash] Message envelope
|
|
23
|
+
# @param event_id [String] Event ID
|
|
24
|
+
# @param publisher_block [Proc] Block that performs the actual publish
|
|
25
|
+
# @return [PublishResult] Result object
|
|
26
|
+
def self.publish(subject:, envelope:, event_id:, &publisher_block)
|
|
27
|
+
new(subject: subject, envelope: envelope, event_id: event_id, publisher_block: publisher_block).publish
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(subject:, envelope:, event_id:, publisher_block:)
|
|
31
|
+
@subject = subject
|
|
32
|
+
@envelope = envelope
|
|
33
|
+
@event_id = event_id
|
|
34
|
+
@publisher_block = publisher_block
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def publish
|
|
38
|
+
# Validate outbox model configuration
|
|
39
|
+
klass = ModelUtils.constantize(NatsPubsub.config.outbox_model)
|
|
40
|
+
|
|
41
|
+
unless ModelUtils.ar_class?(klass)
|
|
42
|
+
Logging.warn(
|
|
43
|
+
"Outbox model #{klass} is not an ActiveRecord model; publishing directly.",
|
|
44
|
+
tag: 'NatsPubsub::OutboxPublisher'
|
|
45
|
+
)
|
|
46
|
+
return @publisher_block.call
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Use repository pattern for database operations
|
|
50
|
+
repo = OutboxRepository.new(klass)
|
|
51
|
+
record = repo.find_or_build(@event_id)
|
|
52
|
+
|
|
53
|
+
# Skip if already sent (idempotency)
|
|
54
|
+
if repo.already_sent?(record)
|
|
55
|
+
Logging.info(
|
|
56
|
+
"Outbox already sent event_id=#{@event_id}; skipping publish.",
|
|
57
|
+
tag: 'NatsPubsub::OutboxPublisher'
|
|
58
|
+
)
|
|
59
|
+
return PublishResult.success(event_id: @event_id, subject: @subject)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Persist pre-publish state
|
|
63
|
+
repo.persist_pre(record, @subject, @envelope)
|
|
64
|
+
|
|
65
|
+
# Attempt to publish
|
|
66
|
+
result = @publisher_block.call
|
|
67
|
+
|
|
68
|
+
# Update record based on result
|
|
69
|
+
if result.success?
|
|
70
|
+
repo.persist_success(record)
|
|
71
|
+
else
|
|
72
|
+
repo.persist_failure(record, result.details || 'Publish failed')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
result
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
# Persist exception if repository and record are available
|
|
78
|
+
repo.persist_exception(record, e) if defined?(repo) && defined?(record)
|
|
79
|
+
|
|
80
|
+
# Return failure result
|
|
81
|
+
Logging.error(
|
|
82
|
+
"Outbox publish failed: #{e.class} #{e.message}",
|
|
83
|
+
tag: 'NatsPubsub::OutboxPublisher'
|
|
84
|
+
)
|
|
85
|
+
PublishResult.failure(
|
|
86
|
+
reason: :exception,
|
|
87
|
+
details: "#{e.class}: #{e.message}",
|
|
88
|
+
subject: @subject,
|
|
89
|
+
error: e
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
attr_reader :subject, :envelope, :event_id, :publisher_block
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/base_repository'
|
|
4
|
+
require_relative '../models/model_utils'
|
|
5
|
+
require_relative '../core/logging'
|
|
6
|
+
|
|
7
|
+
module NatsPubsub
|
|
8
|
+
# Encapsulates AR-backed outbox persistence operations.
|
|
9
|
+
# Inherits common patterns from BaseRepository.
|
|
10
|
+
class OutboxRepository < BaseRepository
|
|
11
|
+
def already_sent?(record)
|
|
12
|
+
has_attribute?(record, :sent_at) && record.sent_at
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def persist_pre(record, subject, envelope)
|
|
16
|
+
now = Time.now.utc
|
|
17
|
+
event_id = envelope['event_id'].to_s
|
|
18
|
+
|
|
19
|
+
attrs = build_pre_publish_attrs(record, event_id, subject, envelope, now)
|
|
20
|
+
assign_attributes(record, attrs)
|
|
21
|
+
save_record!(record)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def persist_success(record)
|
|
25
|
+
attrs = { status: 'sent' }
|
|
26
|
+
attrs[:sent_at] = Time.now.utc if has_attribute?(record, :sent_at)
|
|
27
|
+
update_with_timestamp(record, attrs)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def persist_failure(record, error_msg)
|
|
31
|
+
attrs = { status: 'failed', last_error: error_msg }
|
|
32
|
+
update_with_timestamp(record, attrs)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def persist_exception(record, error)
|
|
36
|
+
return unless record
|
|
37
|
+
|
|
38
|
+
persist_failure(record, "#{error.class}: #{error.message}")
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Logging.warn(
|
|
41
|
+
"Failed to persist outbox failure: #{e.class}: #{e.message}",
|
|
42
|
+
tag: 'NatsPubsub::Publisher'
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Batch operations for improved performance
|
|
47
|
+
def mark_batch_as_sent(records)
|
|
48
|
+
return 0 if records.empty?
|
|
49
|
+
|
|
50
|
+
ids = records.map { |r| model_utils.pk_value(r) }.compact
|
|
51
|
+
return 0 if ids.empty?
|
|
52
|
+
|
|
53
|
+
model = model_for(records.first)
|
|
54
|
+
now = Time.now.utc
|
|
55
|
+
|
|
56
|
+
attrs = { status: 'sent', updated_at: now }
|
|
57
|
+
attrs[:sent_at] = now if model.has_column?(:sent_at)
|
|
58
|
+
|
|
59
|
+
model.where(model.primary_key => ids).update_all(attrs)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def mark_batch_as_failed(records, error_msg)
|
|
63
|
+
return 0 if records.empty?
|
|
64
|
+
|
|
65
|
+
ids = records.map { |r| model_utils.pk_value(r) }.compact
|
|
66
|
+
return 0 if ids.empty?
|
|
67
|
+
|
|
68
|
+
model = model_for(records.first)
|
|
69
|
+
|
|
70
|
+
attrs = {
|
|
71
|
+
status: 'failed',
|
|
72
|
+
last_error: error_msg.to_s.truncate(1000),
|
|
73
|
+
updated_at: Time.now.utc
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
model.where(model.primary_key => ids).update_all(attrs)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cleanup_sent_events(retention_period = 7.days.ago)
|
|
80
|
+
model = outbox_model
|
|
81
|
+
return 0 unless model
|
|
82
|
+
|
|
83
|
+
scope = model.sent
|
|
84
|
+
scope = scope.where('sent_at < ?', retention_period) if model.has_column?(:sent_at)
|
|
85
|
+
scope.delete_all
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def reset_stale_publishing(threshold = 5.minutes.ago)
|
|
89
|
+
model = outbox_model
|
|
90
|
+
return 0 unless model
|
|
91
|
+
|
|
92
|
+
attrs = {
|
|
93
|
+
status: 'pending',
|
|
94
|
+
last_error: 'Reset from stale publishing state',
|
|
95
|
+
updated_at: Time.now.utc
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
model.stale_publishing(threshold).update_all(attrs)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def build_pre_publish_attrs(record, event_id, subject, envelope, timestamp)
|
|
104
|
+
attrs = {
|
|
105
|
+
event_id: event_id,
|
|
106
|
+
subject: subject,
|
|
107
|
+
payload: ModelUtils.json_dump(envelope),
|
|
108
|
+
headers: ModelUtils.json_dump({ 'nats-msg-id' => event_id }),
|
|
109
|
+
status: 'publishing',
|
|
110
|
+
last_error: nil
|
|
111
|
+
}
|
|
112
|
+
attrs[:attempts] = 1 + (record.attempts || 0) if has_attribute?(record, :attempts)
|
|
113
|
+
attrs[:enqueued_at] = (record.enqueued_at || timestamp) if has_attribute?(record, :enqueued_at)
|
|
114
|
+
attrs
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
# Parses and validates arguments for the unified Publisher#publish interface
|
|
5
|
+
# Extracts complex argument parsing logic following Single Responsibility Principle
|
|
6
|
+
#
|
|
7
|
+
# Supports three publishing patterns:
|
|
8
|
+
# 1. Topic-based: publish(topic, message) or publish(topic:, message:)
|
|
9
|
+
# 2. Domain/resource/action: publish(domain:, resource:, action:, payload:)
|
|
10
|
+
# 3. Multi-topic: publish(topics:, message:)
|
|
11
|
+
class PublishArgumentParser
|
|
12
|
+
# Parse result containing method to call and arguments
|
|
13
|
+
ParseResult = Struct.new(:method, :args, :kwargs) do
|
|
14
|
+
def call(publisher)
|
|
15
|
+
publisher.public_send(method, *args, **kwargs)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parse arguments and determine which publishing method to use
|
|
20
|
+
#
|
|
21
|
+
# @param args [Array] Positional arguments
|
|
22
|
+
# @param kwargs [Hash] Keyword arguments
|
|
23
|
+
# @return [ParseResult] Parse result with method and arguments
|
|
24
|
+
# @raise [ArgumentError] if arguments are invalid
|
|
25
|
+
def self.parse(*args, **kwargs)
|
|
26
|
+
# Multi-topic publishing
|
|
27
|
+
if multi_topic?(kwargs)
|
|
28
|
+
return ParseResult.new(
|
|
29
|
+
:publish_to_topics,
|
|
30
|
+
[kwargs[:topics], kwargs[:message]],
|
|
31
|
+
kwargs.except(:topics, :message)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Domain/resource/action pattern
|
|
36
|
+
if domain_resource_action?(kwargs)
|
|
37
|
+
return ParseResult.new(
|
|
38
|
+
:publish_event,
|
|
39
|
+
[kwargs[:domain], kwargs[:resource], kwargs[:action], kwargs[:payload]],
|
|
40
|
+
kwargs.except(:domain, :resource, :action, :payload)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Topic-based pattern (positional args)
|
|
45
|
+
if positional_topic?(args)
|
|
46
|
+
return ParseResult.new(
|
|
47
|
+
:publish_to_topic,
|
|
48
|
+
[args[0], args[1]],
|
|
49
|
+
kwargs
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Topic-based pattern (keyword args)
|
|
54
|
+
if keyword_topic?(kwargs)
|
|
55
|
+
return ParseResult.new(
|
|
56
|
+
:publish_to_topic,
|
|
57
|
+
[kwargs[:topic], kwargs[:message]],
|
|
58
|
+
kwargs.except(:topic, :message)
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Invalid arguments
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
'Invalid arguments. Use publish(topic, message, **opts) or ' \
|
|
65
|
+
'publish(domain:, resource:, action:, payload:, **opts) or ' \
|
|
66
|
+
'publish(topics:, message:, **opts)'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check if arguments match multi-topic pattern
|
|
70
|
+
#
|
|
71
|
+
# @param kwargs [Hash] Keyword arguments
|
|
72
|
+
# @return [Boolean] True if multi-topic pattern
|
|
73
|
+
def self.multi_topic?(kwargs)
|
|
74
|
+
kwargs.key?(:topics) && kwargs.key?(:message)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :multi_topic?
|
|
77
|
+
|
|
78
|
+
# Check if arguments match domain/resource/action pattern
|
|
79
|
+
#
|
|
80
|
+
# @param kwargs [Hash] Keyword arguments
|
|
81
|
+
# @return [Boolean] True if domain/resource/action pattern
|
|
82
|
+
def self.domain_resource_action?(kwargs)
|
|
83
|
+
kwargs.key?(:domain) &&
|
|
84
|
+
kwargs.key?(:resource) &&
|
|
85
|
+
kwargs.key?(:action) &&
|
|
86
|
+
kwargs.key?(:payload)
|
|
87
|
+
end
|
|
88
|
+
private_class_method :domain_resource_action?
|
|
89
|
+
|
|
90
|
+
# Check if arguments match positional topic pattern
|
|
91
|
+
#
|
|
92
|
+
# @param args [Array] Positional arguments
|
|
93
|
+
# @return [Boolean] True if positional topic pattern
|
|
94
|
+
def self.positional_topic?(args)
|
|
95
|
+
args.length >= 2
|
|
96
|
+
end
|
|
97
|
+
private_class_method :positional_topic?
|
|
98
|
+
|
|
99
|
+
# Check if arguments match keyword topic pattern
|
|
100
|
+
#
|
|
101
|
+
# @param kwargs [Hash] Keyword arguments
|
|
102
|
+
# @return [Boolean] True if keyword topic pattern
|
|
103
|
+
def self.keyword_topic?(kwargs)
|
|
104
|
+
kwargs.key?(:topic) && kwargs.key?(:message)
|
|
105
|
+
end
|
|
106
|
+
private_class_method :keyword_topic?
|
|
107
|
+
end
|
|
108
|
+
end
|