jetstream_bridge 3.0.2 → 4.0.1

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.
@@ -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: lambda {
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: lambda {
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: -> { !self.class.has_column?(:event_id) && self.class.has_column?(:event_type) }
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 ||= 'pending' if self.class.has_column?(:status) && status.blank?
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
- # ---- Helpers ----
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 = 'sent' if self.class.has_column?(:status)
84
- self.sent_at = now if self.class.has_column?(:sent_at)
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 = 'failed' if self.class.has_column?(:status)
90
- self.last_error = err_msg if self.class.has_column?(:last_error)
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
@@ -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,6 +3,16 @@
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 = '>'
@@ -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
@@ -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