jetstream_bridge 3.0.1 → 4.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 +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +1147 -82
- data/lib/jetstream_bridge/consumer/consumer.rb +174 -6
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +4 -4
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/message_processor.rb +41 -7
- data/lib/jetstream_bridge/consumer/middleware.rb +154 -0
- data/lib/jetstream_bridge/core/config.rb +150 -9
- data/lib/jetstream_bridge/core/config_preset.rb +99 -0
- data/lib/jetstream_bridge/core/connection.rb +5 -2
- data/lib/jetstream_bridge/core/connection_factory.rb +1 -1
- data/lib/jetstream_bridge/core/duration.rb +21 -37
- data/lib/jetstream_bridge/errors.rb +60 -8
- data/lib/jetstream_bridge/models/event.rb +202 -0
- data/lib/jetstream_bridge/{inbox_event.rb → models/inbox_event.rb} +62 -4
- data/lib/jetstream_bridge/{outbox_event.rb → models/outbox_event.rb} +65 -16
- data/lib/jetstream_bridge/models/publish_result.rb +64 -0
- data/lib/jetstream_bridge/models/subject.rb +56 -4
- data/lib/jetstream_bridge/publisher/batch_publisher.rb +163 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +240 -21
- data/lib/jetstream_bridge/test_helpers.rb +275 -0
- data/lib/jetstream_bridge/topology/overlap_guard.rb +1 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +178 -3
- data/lib/tasks/yard.rake +18 -0
- metadata +11 -4
|
@@ -38,28 +38,22 @@ module JetstreamBridge
|
|
|
38
38
|
presence: true,
|
|
39
39
|
if: -> { self.class.has_column?(:payload) }
|
|
40
40
|
|
|
41
|
-
# Preferred path when event_id exists
|
|
42
41
|
validates :event_id,
|
|
43
42
|
presence: true,
|
|
44
43
|
uniqueness: true,
|
|
45
44
|
if: -> { self.class.has_column?(:event_id) }
|
|
46
45
|
|
|
47
|
-
# Fallback legacy fields when event_id is absent
|
|
48
46
|
validates :resource_type,
|
|
49
47
|
presence: true,
|
|
50
|
-
if:
|
|
51
|
-
!self.class.has_column?(:event_id) && self.class.has_column?(:resource_type)
|
|
52
|
-
}
|
|
48
|
+
if: -> { self.class.has_column?(:resource_type) }
|
|
53
49
|
|
|
54
50
|
validates :resource_id,
|
|
55
51
|
presence: true,
|
|
56
|
-
if:
|
|
57
|
-
!self.class.has_column?(:event_id) && self.class.has_column?(:resource_id)
|
|
58
|
-
}
|
|
52
|
+
if: -> { self.class.has_column?(:resource_id) }
|
|
59
53
|
|
|
60
54
|
validates :event_type,
|
|
61
55
|
presence: true,
|
|
62
|
-
if: -> {
|
|
56
|
+
if: -> { self.class.has_column?(:event_type) }
|
|
63
57
|
|
|
64
58
|
validates :subject,
|
|
65
59
|
presence: true,
|
|
@@ -72,25 +66,80 @@ module JetstreamBridge
|
|
|
72
66
|
# ---- Defaults that do not require schema at load time ----
|
|
73
67
|
before_validation do
|
|
74
68
|
now = Time.now.utc
|
|
75
|
-
self.status ||=
|
|
69
|
+
self.status ||= JetstreamBridge::Config::Status::PENDING if self.class.has_column?(:status) && status.blank?
|
|
76
70
|
self.enqueued_at ||= now if self.class.has_column?(:enqueued_at) && enqueued_at.blank?
|
|
77
71
|
self.attempts = 0 if self.class.has_column?(:attempts) && attempts.nil?
|
|
78
72
|
end
|
|
79
73
|
|
|
80
|
-
# ----
|
|
74
|
+
# ---- Query Scopes ----
|
|
75
|
+
scope :pending, -> { where(status: JetstreamBridge::Config::Status::PENDING) if has_column?(:status) }
|
|
76
|
+
scope :publishing, -> { where(status: JetstreamBridge::Config::Status::PUBLISHING) if has_column?(:status) }
|
|
77
|
+
scope :sent, -> { where(status: JetstreamBridge::Config::Status::SENT) if has_column?(:status) }
|
|
78
|
+
scope :failed, -> { where(status: JetstreamBridge::Config::Status::FAILED) if has_column?(:status) }
|
|
79
|
+
scope :stale, lambda {
|
|
80
|
+
pending.where('created_at < ?', 1.hour.ago) if has_column?(:created_at) && has_column?(:status)
|
|
81
|
+
}
|
|
82
|
+
scope :by_resource_type, lambda { |type|
|
|
83
|
+
where(resource_type: type) if has_column?(:resource_type)
|
|
84
|
+
}
|
|
85
|
+
scope :by_event_type, lambda { |type|
|
|
86
|
+
where(event_type: type) if has_column?(:event_type)
|
|
87
|
+
}
|
|
88
|
+
scope :recent, lambda { |limit = 100|
|
|
89
|
+
order(created_at: :desc).limit(limit) if has_column?(:created_at)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# ---- Class Methods ----
|
|
93
|
+
class << self
|
|
94
|
+
# Retry failed events
|
|
95
|
+
#
|
|
96
|
+
# @param limit [Integer] Maximum number of events to retry
|
|
97
|
+
# @return [Integer] Number of events reset for retry
|
|
98
|
+
def retry_failed(limit: 100)
|
|
99
|
+
return 0 unless has_column?(:status)
|
|
100
|
+
|
|
101
|
+
failed.limit(limit).update_all(
|
|
102
|
+
status: JetstreamBridge::Config::Status::PENDING,
|
|
103
|
+
attempts: 0,
|
|
104
|
+
last_error: nil
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Clean up old sent events
|
|
109
|
+
#
|
|
110
|
+
# @param older_than [ActiveSupport::Duration] Age threshold
|
|
111
|
+
# @return [Integer] Number of records deleted
|
|
112
|
+
def cleanup_sent(older_than: 7.days)
|
|
113
|
+
return 0 unless has_column?(:status) && has_column?(:sent_at)
|
|
114
|
+
|
|
115
|
+
sent.where('sent_at < ?', older_than.ago).delete_all
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ---- Instance Methods ----
|
|
81
120
|
def mark_sent!
|
|
82
121
|
now = Time.now.utc
|
|
83
|
-
self.status =
|
|
84
|
-
self.sent_at = now
|
|
122
|
+
self.status = JetstreamBridge::Config::Status::SENT if self.class.has_column?(:status)
|
|
123
|
+
self.sent_at = now if self.class.has_column?(:sent_at)
|
|
85
124
|
save!
|
|
86
125
|
end
|
|
87
126
|
|
|
88
127
|
def mark_failed!(err_msg)
|
|
89
|
-
self.status =
|
|
90
|
-
self.last_error = err_msg
|
|
128
|
+
self.status = JetstreamBridge::Config::Status::FAILED if self.class.has_column?(:status)
|
|
129
|
+
self.last_error = err_msg if self.class.has_column?(:last_error)
|
|
91
130
|
save!
|
|
92
131
|
end
|
|
93
132
|
|
|
133
|
+
def retry!
|
|
134
|
+
return false unless self.class.has_column?(:status)
|
|
135
|
+
|
|
136
|
+
update!(
|
|
137
|
+
status: JetstreamBridge::Config::Status::PENDING,
|
|
138
|
+
attempts: 0,
|
|
139
|
+
last_error: nil
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
94
143
|
def payload_hash
|
|
95
144
|
v = self[:payload]
|
|
96
145
|
case v
|
|
@@ -109,7 +158,7 @@ module JetstreamBridge
|
|
|
109
158
|
# Shim: loud failure if AR isn't present but someone calls the model.
|
|
110
159
|
class OutboxEvent
|
|
111
160
|
class << self
|
|
112
|
-
def method_missing(method_name, *_args, &
|
|
161
|
+
def method_missing(method_name, *_args, &)
|
|
113
162
|
raise_missing_ar!('Outbox', method_name)
|
|
114
163
|
end
|
|
115
164
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
module Models
|
|
5
|
+
# Result object returned from publish operations
|
|
6
|
+
#
|
|
7
|
+
# @example Checking result status
|
|
8
|
+
# result = JetstreamBridge.publish(event_type: "user.created", payload: { id: 1 })
|
|
9
|
+
# if result.success?
|
|
10
|
+
# puts "Published with event_id: #{result.event_id}"
|
|
11
|
+
# else
|
|
12
|
+
# puts "Failed: #{result.error.message}"
|
|
13
|
+
# end
|
|
14
|
+
class PublishResult
|
|
15
|
+
attr_reader :event_id, :subject, :error, :duplicate
|
|
16
|
+
|
|
17
|
+
# @param success [Boolean] Whether the publish was successful
|
|
18
|
+
# @param event_id [String] The event ID that was published
|
|
19
|
+
# @param subject [String] The NATS subject the event was published to
|
|
20
|
+
# @param error [Exception, nil] Any error that occurred
|
|
21
|
+
# @param duplicate [Boolean] Whether NATS detected this as a duplicate
|
|
22
|
+
def initialize(success:, event_id:, subject:, error: nil, duplicate: false)
|
|
23
|
+
@success = success
|
|
24
|
+
@event_id = event_id
|
|
25
|
+
@subject = subject
|
|
26
|
+
@error = error
|
|
27
|
+
@duplicate = duplicate
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] True if the publish was successful
|
|
32
|
+
def success?
|
|
33
|
+
@success
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] True if the publish failed
|
|
37
|
+
def failure?
|
|
38
|
+
!@success
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] True if NATS detected this as a duplicate message
|
|
42
|
+
def duplicate?
|
|
43
|
+
@duplicate
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Hash] Hash representation of the result
|
|
47
|
+
def to_h
|
|
48
|
+
{
|
|
49
|
+
success: @success,
|
|
50
|
+
event_id: @event_id,
|
|
51
|
+
subject: @subject,
|
|
52
|
+
duplicate: @duplicate,
|
|
53
|
+
error: @error&.message
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
alias to_hash to_h
|
|
58
|
+
|
|
59
|
+
def inspect
|
|
60
|
+
"#<#{self.class.name} success=#{@success} event_id=#{@event_id} duplicate=#{@duplicate}>"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
module JetstreamBridge
|
|
4
4
|
module Models
|
|
5
5
|
# Value object representing a NATS subject
|
|
6
|
+
#
|
|
7
|
+
# @example Creating a subject
|
|
8
|
+
# subject = Subject.source(env: "production", app_name: "api", dest: "worker")
|
|
9
|
+
# subject.to_s # => "production.api.sync.worker"
|
|
10
|
+
#
|
|
11
|
+
# @example Parsing a subject string
|
|
12
|
+
# subject = Subject.parse("production.api.sync.worker")
|
|
13
|
+
# subject.env # => "production"
|
|
14
|
+
# subject.source_app # => "api"
|
|
15
|
+
# subject.dest_app # => "worker"
|
|
6
16
|
class Subject
|
|
7
17
|
WILDCARD_SINGLE = '*'
|
|
8
18
|
WILDCARD_MULTI = '>'
|
|
9
19
|
SEPARATOR = '.'
|
|
10
|
-
INVALID_CHARS = /[#{Regexp.escape(WILDCARD_SINGLE + WILDCARD_MULTI + SEPARATOR)}]
|
|
20
|
+
INVALID_CHARS = /[#{Regexp.escape(WILDCARD_SINGLE + WILDCARD_MULTI + SEPARATOR)}]/
|
|
11
21
|
|
|
12
22
|
attr_reader :value, :tokens
|
|
13
23
|
|
|
@@ -29,8 +39,49 @@ module JetstreamBridge
|
|
|
29
39
|
new("#{env}.#{source}.sync.#{app_name}")
|
|
30
40
|
end
|
|
31
41
|
|
|
32
|
-
def self.dlq(env:)
|
|
33
|
-
new("#{env}.sync.dlq")
|
|
42
|
+
def self.dlq(env:, app_name:)
|
|
43
|
+
new("#{env}.#{app_name}.sync.dlq")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Parse a subject string into a Subject object with metadata
|
|
47
|
+
#
|
|
48
|
+
# @param string [String] Subject string (e.g., "production.api.sync.worker")
|
|
49
|
+
# @return [Subject] Parsed subject
|
|
50
|
+
def self.parse(string)
|
|
51
|
+
new(string)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get environment from subject (first token)
|
|
55
|
+
#
|
|
56
|
+
# @return [String, nil] Environment
|
|
57
|
+
def env
|
|
58
|
+
@tokens[0]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get source application from subject
|
|
62
|
+
#
|
|
63
|
+
# For regular subjects: {env}.{source_app}.sync.{dest}
|
|
64
|
+
# For DLQ subjects: {env}.{app_name}.sync.dlq
|
|
65
|
+
#
|
|
66
|
+
# @return [String, nil] Source application
|
|
67
|
+
def source_app
|
|
68
|
+
@tokens[1]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get destination application from subject
|
|
72
|
+
#
|
|
73
|
+
# @return [String, nil] Destination application
|
|
74
|
+
def dest_app
|
|
75
|
+
@tokens[3]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if this is a DLQ subject
|
|
79
|
+
#
|
|
80
|
+
# DLQ subjects follow the pattern: {env}.{app}.sync.dlq
|
|
81
|
+
#
|
|
82
|
+
# @return [Boolean] True if this is a DLQ subject
|
|
83
|
+
def dlq?
|
|
84
|
+
@tokens.length == 4 && @tokens[2] == 'sync' && @tokens[3] == 'dlq'
|
|
34
85
|
end
|
|
35
86
|
|
|
36
87
|
# Check if this subject matches a pattern
|
|
@@ -66,8 +117,9 @@ module JetstreamBridge
|
|
|
66
117
|
def self.validate_component!(value, name)
|
|
67
118
|
str = value.to_s
|
|
68
119
|
if str.match?(INVALID_CHARS)
|
|
120
|
+
wildcards = "#{SEPARATOR}, #{WILDCARD_SINGLE}, #{WILDCARD_MULTI}"
|
|
69
121
|
raise ArgumentError,
|
|
70
|
-
"#{name} cannot contain NATS wildcards (#{
|
|
122
|
+
"#{name} cannot contain NATS wildcards (#{wildcards}): #{value.inspect}"
|
|
71
123
|
end
|
|
72
124
|
raise ArgumentError, "#{name} cannot be empty" if str.strip.empty?
|
|
73
125
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
require_relative '../core/logging'
|
|
5
|
+
require_relative '../models/publish_result'
|
|
6
|
+
|
|
7
|
+
module JetstreamBridge
|
|
8
|
+
# Batch publisher for efficient bulk event publishing.
|
|
9
|
+
#
|
|
10
|
+
# BatchPublisher allows you to queue multiple events and publish them together,
|
|
11
|
+
# providing detailed results about successes and failures. Each event is published
|
|
12
|
+
# independently, so partial failures are possible.
|
|
13
|
+
#
|
|
14
|
+
# @example Publishing multiple events
|
|
15
|
+
# results = JetstreamBridge.publish_batch do |batch|
|
|
16
|
+
# users.each do |user|
|
|
17
|
+
# batch.add(event_type: "user.created", payload: { id: user.id })
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# puts "Published: #{results.successful_count}, Failed: #{results.failed_count}"
|
|
22
|
+
# results.errors.each { |e| logger.error("Failed: #{e[:event_id]}") }
|
|
23
|
+
#
|
|
24
|
+
# @example Checking for failures
|
|
25
|
+
# results = JetstreamBridge.publish_batch do |batch|
|
|
26
|
+
# batch.add(event_type: "order.created", payload: { id: 1 })
|
|
27
|
+
# batch.add(event_type: "order.updated", payload: { id: 2 })
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# if results.failure?
|
|
31
|
+
# logger.warn "Some events failed to publish"
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
class BatchPublisher
|
|
35
|
+
# Result object for batch operations.
|
|
36
|
+
#
|
|
37
|
+
# Contains aggregated results from a batch publish operation, including
|
|
38
|
+
# success/failure counts and detailed error information.
|
|
39
|
+
#
|
|
40
|
+
# @example Checking results
|
|
41
|
+
# results = JetstreamBridge.publish_batch { |b| ... }
|
|
42
|
+
# puts "Success: #{results.successful_count}"
|
|
43
|
+
# puts "Failed: #{results.failed_count}"
|
|
44
|
+
# puts "Partial: #{results.partial_success?}"
|
|
45
|
+
#
|
|
46
|
+
class BatchResult
|
|
47
|
+
# @return [Array<Models::PublishResult>] All individual publish results
|
|
48
|
+
attr_reader :results
|
|
49
|
+
# @return [Integer] Number of successfully published events
|
|
50
|
+
attr_reader :successful_count
|
|
51
|
+
# @return [Integer] Number of failed events
|
|
52
|
+
attr_reader :failed_count
|
|
53
|
+
# @return [Array<Hash>] Array of error details with event_id and error
|
|
54
|
+
attr_reader :errors
|
|
55
|
+
|
|
56
|
+
# @param results [Array<Models::PublishResult>] Individual publish results
|
|
57
|
+
# @api private
|
|
58
|
+
def initialize(results)
|
|
59
|
+
@results = results
|
|
60
|
+
@successful_count = results.count(&:success?)
|
|
61
|
+
@failed_count = results.count(&:failure?)
|
|
62
|
+
@errors = results.select(&:failure?).map { |r| { event_id: r.event_id, error: r.error } }
|
|
63
|
+
freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if all events published successfully.
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] True if no failures
|
|
69
|
+
def success?
|
|
70
|
+
@failed_count.zero?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if any events failed to publish.
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean] True if any failures
|
|
76
|
+
def failure?
|
|
77
|
+
!success?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if some (but not all) events published successfully.
|
|
81
|
+
#
|
|
82
|
+
# @return [Boolean] True if both successes and failures exist
|
|
83
|
+
def partial_success?
|
|
84
|
+
@successful_count.positive? && @failed_count.positive?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_h
|
|
88
|
+
{
|
|
89
|
+
successful_count: @successful_count,
|
|
90
|
+
failed_count: @failed_count,
|
|
91
|
+
total_count: @results.size,
|
|
92
|
+
errors: @errors
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
alias to_hash to_h
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def initialize(publisher = nil)
|
|
100
|
+
@publisher = publisher || Publisher.new
|
|
101
|
+
@events = []
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Add an event to the batch
|
|
105
|
+
#
|
|
106
|
+
# @param event_or_hash [Hash] Event data
|
|
107
|
+
# @param resource_type [String, nil] Resource type
|
|
108
|
+
# @param event_type [String, nil] Event type
|
|
109
|
+
# @param payload [Hash, nil] Payload data
|
|
110
|
+
# @param options [Hash] Additional options
|
|
111
|
+
# @return [self] Returns self for chaining
|
|
112
|
+
def add(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, **options)
|
|
113
|
+
@events << {
|
|
114
|
+
event_or_hash: event_or_hash,
|
|
115
|
+
resource_type: resource_type,
|
|
116
|
+
event_type: event_type,
|
|
117
|
+
payload: payload,
|
|
118
|
+
options: options
|
|
119
|
+
}
|
|
120
|
+
self
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Publish all events in the batch
|
|
124
|
+
#
|
|
125
|
+
# @return [BatchResult] Result containing success/failure counts
|
|
126
|
+
def publish
|
|
127
|
+
results = @events.map do |event_data|
|
|
128
|
+
@publisher.publish(
|
|
129
|
+
event_data[:event_or_hash],
|
|
130
|
+
resource_type: event_data[:resource_type],
|
|
131
|
+
event_type: event_data[:event_type],
|
|
132
|
+
payload: event_data[:payload],
|
|
133
|
+
**event_data[:options]
|
|
134
|
+
)
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
Models::PublishResult.new(
|
|
137
|
+
success: false,
|
|
138
|
+
event_id: 'unknown',
|
|
139
|
+
subject: 'unknown',
|
|
140
|
+
error: e
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
BatchResult.new(results)
|
|
145
|
+
ensure
|
|
146
|
+
@events.clear
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Get number of events queued
|
|
150
|
+
#
|
|
151
|
+
# @return [Integer] Number of events in batch
|
|
152
|
+
def size
|
|
153
|
+
@events.size
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
alias count size
|
|
157
|
+
alias length size
|
|
158
|
+
|
|
159
|
+
def empty?
|
|
160
|
+
@events.empty?
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|