pigeon-rb 0.1.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +343 -0
  3. data/lib/pigeon/active_job_integration.rb +32 -0
  4. data/lib/pigeon/api.rb +200 -0
  5. data/lib/pigeon/configuration.rb +161 -0
  6. data/lib/pigeon/core.rb +104 -0
  7. data/lib/pigeon/encryption.rb +213 -0
  8. data/lib/pigeon/generators/hanami/migration_generator.rb +89 -0
  9. data/lib/pigeon/generators/rails/install_generator.rb +32 -0
  10. data/lib/pigeon/generators/rails/migration_generator.rb +20 -0
  11. data/lib/pigeon/generators/rails/templates/create_outbox_messages.rb.erb +34 -0
  12. data/lib/pigeon/generators/rails/templates/initializer.rb.erb +88 -0
  13. data/lib/pigeon/hanami_integration.rb +78 -0
  14. data/lib/pigeon/health_check/kafka.rb +37 -0
  15. data/lib/pigeon/health_check/processor.rb +70 -0
  16. data/lib/pigeon/health_check/queue.rb +69 -0
  17. data/lib/pigeon/health_check.rb +63 -0
  18. data/lib/pigeon/logging/structured_logger.rb +181 -0
  19. data/lib/pigeon/metrics/collector.rb +200 -0
  20. data/lib/pigeon/mock_producer.rb +18 -0
  21. data/lib/pigeon/models/adapters/active_record_adapter.rb +133 -0
  22. data/lib/pigeon/models/adapters/rom_adapter.rb +150 -0
  23. data/lib/pigeon/models/outbox_message.rb +182 -0
  24. data/lib/pigeon/monitoring.rb +113 -0
  25. data/lib/pigeon/outbox.rb +61 -0
  26. data/lib/pigeon/processor/background_processor.rb +109 -0
  27. data/lib/pigeon/processor.rb +798 -0
  28. data/lib/pigeon/publisher.rb +524 -0
  29. data/lib/pigeon/railtie.rb +29 -0
  30. data/lib/pigeon/schema.rb +35 -0
  31. data/lib/pigeon/security.rb +30 -0
  32. data/lib/pigeon/serializer.rb +77 -0
  33. data/lib/pigeon/tasks/pigeon.rake +64 -0
  34. data/lib/pigeon/trace_api.rb +37 -0
  35. data/lib/pigeon/tracing/core.rb +119 -0
  36. data/lib/pigeon/tracing/messaging.rb +144 -0
  37. data/lib/pigeon/tracing.rb +107 -0
  38. data/lib/pigeon/version.rb +5 -0
  39. data/lib/pigeon.rb +52 -0
  40. metadata +127 -0
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pigeon
4
+ module Metrics
5
+ # Metrics collector for Pigeon
6
+ class Collector
7
+ # Initialize a new metrics collector
8
+ # @param adapter [Object, nil] Optional metrics adapter (e.g., Prometheus, StatsD)
9
+ def initialize(adapter = nil)
10
+ @adapter = adapter
11
+ @counters = {}
12
+ @gauges = {}
13
+ @histograms = {}
14
+ end
15
+
16
+ # Increment a counter
17
+ # @param name [Symbol, String] Counter name
18
+ # @param value [Integer] Value to increment by (default: 1)
19
+ # @param tags [Hash] Optional tags for the metric
20
+ # @return [Integer] New counter value
21
+ def increment(name, value = 1, tags = {})
22
+ counter_key = metric_key(name, tags)
23
+ @counters[counter_key] ||= 0
24
+ @counters[counter_key] += value
25
+
26
+ # If an adapter is provided, delegate to it
27
+ @adapter&.increment(name, value, tags) if @adapter.respond_to?(:increment)
28
+
29
+ @counters[counter_key]
30
+ end
31
+
32
+ # Set a gauge value
33
+ # @param name [Symbol, String] Gauge name
34
+ # @param value [Numeric] Value to set
35
+ # @param tags [Hash] Optional tags for the metric
36
+ # @return [Numeric] Set value
37
+ def gauge(name, value, tags = {})
38
+ gauge_key = metric_key(name, tags)
39
+ @gauges[gauge_key] = value
40
+
41
+ # If an adapter is provided, delegate to it
42
+ @adapter&.gauge(name, value, tags) if @adapter.respond_to?(:gauge)
43
+
44
+ value
45
+ end
46
+
47
+ # Record a histogram value
48
+ # @param name [Symbol, String] Histogram name
49
+ # @param value [Numeric] Value to record
50
+ # @param tags [Hash] Optional tags for the metric
51
+ # @return [Array] All recorded values for this histogram
52
+ def histogram(name, value, tags = {})
53
+ histogram_key = metric_key(name, tags)
54
+ @histograms[histogram_key] ||= []
55
+ @histograms[histogram_key] << value
56
+
57
+ # If an adapter is provided, delegate to it
58
+ @adapter&.histogram(name, value, tags) if @adapter.respond_to?(:histogram)
59
+
60
+ @histograms[histogram_key]
61
+ end
62
+
63
+ # Get the current value of a counter
64
+ # @param name [Symbol, String] Counter name
65
+ # @param tags [Hash] Optional tags for the metric
66
+ # @return [Integer] Current counter value
67
+ def get_counter(name, tags = {})
68
+ @counters[metric_key(name, tags)] || 0
69
+ end
70
+
71
+ # Get the current value of a gauge
72
+ # @param name [Symbol, String] Gauge name
73
+ # @param tags [Hash] Optional tags for the metric
74
+ # @return [Numeric, nil] Current gauge value
75
+ def get_gauge(name, tags = {})
76
+ @gauges[metric_key(name, tags)]
77
+ end
78
+
79
+ # Get all recorded values for a histogram
80
+ # @param name [Symbol, String] Histogram name
81
+ # @param tags [Hash] Optional tags for the metric
82
+ # @return [Array] All recorded values
83
+ def get_histogram(name, tags = {})
84
+ @histograms[metric_key(name, tags)] || []
85
+ end
86
+
87
+ # Get all metrics
88
+ # @return [Hash] All metrics
89
+ def all_metrics
90
+ {
91
+ counters: @counters,
92
+ gauges: @gauges,
93
+ histograms: @histograms
94
+ }
95
+ end
96
+
97
+ # Reset all metrics
98
+ # @return [void]
99
+ def reset
100
+ @counters = {}
101
+ @gauges = {}
102
+ @histograms = {}
103
+ end
104
+
105
+ # Get metrics in a format suitable for monitoring systems
106
+ # @return [Hash] Formatted metrics
107
+ def metrics_for_monitoring
108
+ {
109
+ counters: format_counters,
110
+ gauges: format_gauges,
111
+ histograms: format_histograms
112
+ }
113
+ end
114
+
115
+ # Format counters for monitoring
116
+ # @return [Array<Hash>] Formatted counters
117
+ def format_counters
118
+ @counters.map do |key, value|
119
+ name, tags = parse_metric_key(key)
120
+ {
121
+ name: name,
122
+ value: value,
123
+ tags: tags
124
+ }
125
+ end
126
+ end
127
+
128
+ # Format gauges for monitoring
129
+ # @return [Array<Hash>] Formatted gauges
130
+ def format_gauges
131
+ @gauges.map do |key, value|
132
+ name, tags = parse_metric_key(key)
133
+ {
134
+ name: name,
135
+ value: value,
136
+ tags: tags
137
+ }
138
+ end
139
+ end
140
+
141
+ # Format histograms for monitoring
142
+ # @return [Array<Hash>] Formatted histograms with statistics
143
+ def format_histograms
144
+ @histograms.map do |key, values|
145
+ next if values.empty?
146
+
147
+ name, tags = parse_metric_key(key)
148
+ sorted = values.sort
149
+
150
+ {
151
+ name: name,
152
+ count: values.size,
153
+ min: sorted.first,
154
+ max: sorted.last,
155
+ mean: values.sum / values.size.to_f,
156
+ median: sorted[values.size / 2],
157
+ p95: sorted[(values.size * 0.95).floor],
158
+ p99: sorted[(values.size * 0.99).floor],
159
+ tags: tags
160
+ }
161
+ end.compact
162
+ end
163
+
164
+ private
165
+
166
+ # Generate a unique key for a metric based on name and tags
167
+ # @param name [Symbol, String] Metric name
168
+ # @param tags [Hash] Tags for the metric
169
+ # @return [String] Unique metric key
170
+ def metric_key(name, tags)
171
+ return name.to_s if tags.nil? || tags.empty?
172
+
173
+ # Sort tags by key to ensure consistent key generation
174
+ sorted_tags = tags.sort_by { |k, _| k.to_s }
175
+ tag_str = sorted_tags.map { |k, v| "#{k}:#{v}" }.join(",")
176
+ "#{name}[#{tag_str}]"
177
+ end
178
+
179
+ # Parse a metric key into name and tags
180
+ # @param key [String] Metric key
181
+ # @return [Array<String, Hash>] Name and tags
182
+ def parse_metric_key(key)
183
+ if key.include?("[") && key.end_with?("]")
184
+ name, tags_str = key.split("[", 2)
185
+ tags_str = tags_str[0..-2] # Remove trailing "]"
186
+
187
+ tags = {}
188
+ tags_str.split(",").each do |tag_pair|
189
+ tag_key, tag_value = tag_pair.split(":", 2)
190
+ tags[tag_key] = tag_value if tag_key && tag_value
191
+ end
192
+
193
+ [name, tags]
194
+ else
195
+ [key, {}]
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pigeon
4
+ # Mock producer for testing
5
+ class MockProducer
6
+ def produce_sync?(_payload, **_options)
7
+ true
8
+ end
9
+
10
+ def produce_async?(_payload, **_options)
11
+ true
12
+ end
13
+
14
+ # For compatibility with the real Karafka producer
15
+ alias produce_sync produce_sync?
16
+ alias produce_async produce_async?
17
+ end
18
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pigeon
4
+ module Models
5
+ module Adapters
6
+ # ActiveRecord adapter for OutboxMessage
7
+ class ActiveRecordAdapter < Pigeon::Models::OutboxMessage
8
+ # Define the ActiveRecord model
9
+ def self.define_model
10
+ return @model if @model
11
+
12
+ # Define the ActiveRecord model class
13
+ @model = Class.new(ActiveRecord::Base) do
14
+ self.table_name = "outbox_messages"
15
+
16
+ # Validations
17
+ validates :topic, presence: true
18
+ validates :payload, presence: true
19
+ validates :status, presence: true, inclusion: { in: Pigeon::Models::OutboxMessage::STATUSES }
20
+ validates :retry_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
21
+
22
+ # Serialize headers as JSON
23
+ serialize :headers, JSON if respond_to?(:serialize)
24
+ end
25
+
26
+ # Return the model class
27
+ @model
28
+ end
29
+
30
+ # Get the ActiveRecord model class
31
+ # @return [Class] ActiveRecord model class
32
+ def self.model
33
+ define_model
34
+ end
35
+
36
+ # Create a new outbox message
37
+ # @param attributes [Hash] Message attributes
38
+ # @return [OutboxMessage] New message instance
39
+ def self.create(attributes = {})
40
+ record = model.create!(prepare_attributes(attributes))
41
+ new_from_record(record)
42
+ end
43
+
44
+ # Find a message by ID
45
+ # @param id [String, Integer] Message ID
46
+ # @return [OutboxMessage, nil] Message instance or nil if not found
47
+ def self.find(id)
48
+ record = model.find_by(id: id)
49
+ record ? new_from_record(record) : nil
50
+ end
51
+
52
+ # Find messages by status
53
+ # @param status [String] Message status
54
+ # @param limit [Integer] Maximum number of messages to return
55
+ # @return [Array<OutboxMessage>] Array of message instances
56
+ def self.find_by_status(status, limit = 100)
57
+ records = model.where(status: status).order(created_at: :asc).limit(limit)
58
+ records.map { |record| new_from_record(record) }
59
+ end
60
+
61
+ # Find messages ready for retry
62
+ # @param limit [Integer] Maximum number of messages to return
63
+ # @return [Array<OutboxMessage>] Array of message instances
64
+ def self.find_ready_for_retry(limit = 100)
65
+ now = Time.now
66
+ records = model.where(status: "pending")
67
+ .where("next_retry_at IS NULL OR next_retry_at <= ?", now)
68
+ .order(created_at: :asc)
69
+ .limit(limit)
70
+ records.map { |record| new_from_record(record) }
71
+ end
72
+
73
+ # Count messages by status
74
+ # @param status [String] Message status
75
+ # @return [Integer] Count of messages with the given status
76
+ def self.count_by_status(status)
77
+ model.where(status: status).count
78
+ end
79
+
80
+ # Find the oldest message by status
81
+ # @param status [String] Message status
82
+ # @return [OutboxMessage, nil] Oldest message or nil if none found
83
+ def self.find_oldest_by_status(status)
84
+ record = model.where(status: status).order(created_at: :asc).first
85
+ record ? new_from_record(record) : nil
86
+ end
87
+
88
+ # Create a new OutboxMessage instance from an ActiveRecord record
89
+ # @param record [ActiveRecord::Base] ActiveRecord record
90
+ # @return [OutboxMessage] New message instance
91
+ def self.new_from_record(record)
92
+ attributes = {}
93
+ ATTRIBUTES.each do |attr|
94
+ attributes[attr] = record.send(attr) if record.respond_to?(attr)
95
+ end
96
+ new(attributes).tap { |msg| msg.instance_variable_set(:@record, record) }
97
+ end
98
+
99
+ # Prepare attributes for ActiveRecord
100
+ # @param attributes [Hash] Raw attributes
101
+ # @return [Hash] Prepared attributes
102
+ def self.prepare_attributes(attributes)
103
+ attributes = attributes.dup
104
+
105
+ # Convert Time objects to the format expected by ActiveRecord
106
+ %i[created_at updated_at published_at next_retry_at].each do |attr|
107
+ attributes[attr] = attributes[attr].to_time if attributes[attr].is_a?(Time)
108
+ end
109
+
110
+ # Ensure headers is a hash
111
+ attributes[:headers] ||= {}
112
+
113
+ attributes
114
+ end
115
+
116
+ # Save the message
117
+ # @return [Boolean] Whether the save was successful
118
+ def save
119
+ if @record
120
+ @record.attributes = self.class.prepare_attributes(@attributes)
121
+ @record.save
122
+ else
123
+ @record = self.class.model.create!(self.class.prepare_attributes(@attributes))
124
+ true
125
+ end
126
+ rescue ActiveRecord::RecordInvalid => e
127
+ Pigeon.config.logger.error("Failed to save outbox message: #{e.message}")
128
+ false
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pigeon
4
+ module Models
5
+ module Adapters
6
+ # ROM adapter for OutboxMessage (for Hanami applications)
7
+ class RomAdapter < Pigeon::Models::OutboxMessage
8
+ # Define the ROM relation
9
+ def self.define_relation
10
+ return @relation if @relation
11
+
12
+ # Get the ROM container
13
+ container = Hanami.app["persistence.rom"]
14
+
15
+ # Define the relation if it doesn't exist
16
+ unless container.relations.key?(:outbox_messages)
17
+ container.register_relation(Class.new(ROM::Relation[:sql]) do
18
+ schema(:outbox_messages, infer: true)
19
+
20
+ # Query methods
21
+ def by_status(status)
22
+ where(status: status).order(:created_at)
23
+ end
24
+
25
+ def ready_for_retry
26
+ now = Time.now
27
+ where(status: "pending")
28
+ .where { next_retry_at.nil? | (next_retry_at <= now) }
29
+ .order(:created_at)
30
+ end
31
+ end)
32
+ end
33
+
34
+ # Get the relation
35
+ @relation = container.relations[:outbox_messages]
36
+ end
37
+
38
+ # Get the ROM relation
39
+ # @return [ROM::Relation] ROM relation
40
+ def self.relation
41
+ define_relation
42
+ end
43
+
44
+ # Get the ROM repository
45
+ # @return [ROM::Repository] ROM repository
46
+ def self.repository
47
+ @repository ||= Hanami.app["repositories.outbox_messages"]
48
+ end
49
+
50
+ # Create a new outbox message
51
+ # @param attributes [Hash] Message attributes
52
+ # @return [OutboxMessage] New message instance
53
+ def self.create(attributes = {})
54
+ attributes = prepare_attributes(attributes)
55
+ record = repository.create(attributes)
56
+ new_from_record(record)
57
+ end
58
+
59
+ # Find a message by ID
60
+ # @param id [String, Integer] Message ID
61
+ # @return [OutboxMessage, nil] Message instance or nil if not found
62
+ def self.find(id)
63
+ record = repository.find(id)
64
+ record ? new_from_record(record) : nil
65
+ rescue ROM::TupleCountMismatchError
66
+ nil
67
+ end
68
+
69
+ # Find messages by status
70
+ # @param status [String] Message status
71
+ # @param limit [Integer] Maximum number of messages to return
72
+ # @return [Array<OutboxMessage>] Array of message instances
73
+ def self.find_by_status(status, limit = 100)
74
+ records = relation.by_status(status).limit(limit).to_a
75
+ records.map { |record| new_from_record(record) }
76
+ end
77
+
78
+ # Find messages ready for retry
79
+ # @param limit [Integer] Maximum number of messages to return
80
+ # @return [Array<OutboxMessage>] Array of message instances
81
+ def self.find_ready_for_retry(limit = 100)
82
+ records = relation.ready_for_retry.limit(limit).to_a
83
+ records.map { |record| new_from_record(record) }
84
+ end
85
+
86
+ # Count messages by status
87
+ # @param status [String] Message status
88
+ # @return [Integer] Count of messages with the given status
89
+ def self.count_by_status(status)
90
+ relation.by_status(status).count
91
+ end
92
+
93
+ # Find the oldest message by status
94
+ # @param status [String] Message status
95
+ # @return [OutboxMessage, nil] Oldest message or nil if none found
96
+ def self.find_oldest_by_status(status)
97
+ record = relation.by_status(status).order(:created_at).limit(1).one
98
+ record ? new_from_record(record) : nil
99
+ end
100
+
101
+ # Create a new OutboxMessage instance from a ROM record
102
+ # @param record [ROM::Struct] ROM record
103
+ # @return [OutboxMessage] New message instance
104
+ def self.new_from_record(record)
105
+ attributes = {}
106
+ ATTRIBUTES.each do |attr|
107
+ attributes[attr] = record.send(attr) if record.respond_to?(attr)
108
+ end
109
+ new(attributes).tap { |msg| msg.instance_variable_set(:@record, record) }
110
+ end
111
+
112
+ # Prepare attributes for ROM
113
+ # @param attributes [Hash] Raw attributes
114
+ # @return [Hash] Prepared attributes
115
+ def self.prepare_attributes(attributes)
116
+ attributes = attributes.dup
117
+
118
+ # Convert Time objects to the format expected by ROM
119
+ %i[created_at updated_at published_at next_retry_at].each do |attr|
120
+ attributes[attr] = attributes[attr].to_time if attributes[attr].is_a?(Time)
121
+ end
122
+
123
+ # Ensure headers is a hash
124
+ attributes[:headers] ||= {}
125
+
126
+ attributes
127
+ end
128
+
129
+ # Save the message
130
+ # @return [Boolean] Whether the save was successful
131
+ def save
132
+ if @record
133
+ # Update existing record
134
+ id = @record.id
135
+ attributes = self.class.prepare_attributes(@attributes)
136
+ self.class.repository.update(id, attributes)
137
+ else
138
+ # Create new record
139
+ attributes = self.class.prepare_attributes(@attributes)
140
+ @record = self.class.repository.create(attributes)
141
+ end
142
+ true
143
+ rescue StandardError => e
144
+ Pigeon.config.logger.error("Failed to save outbox message: #{e.message}")
145
+ false
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pigeon
4
+ module Models
5
+ # Base class for outbox message model
6
+ # This is a framework-agnostic representation of the outbox message
7
+ class OutboxMessage
8
+ # Attributes that should be present in all framework implementations
9
+ ATTRIBUTES = %i[
10
+ id
11
+ topic
12
+ key
13
+ headers
14
+ partition
15
+ payload
16
+ status
17
+ retry_count
18
+ max_retries
19
+ error_message
20
+ correlation_id
21
+ created_at
22
+ updated_at
23
+ published_at
24
+ next_retry_at
25
+ ].freeze
26
+
27
+ # Valid status values
28
+ STATUSES = %w[pending processing published failed].freeze
29
+
30
+ # Default values for attributes
31
+ DEFAULTS = {
32
+ status: "pending",
33
+ retry_count: 0,
34
+ headers: {},
35
+ created_at: -> { Time.now },
36
+ updated_at: -> { Time.now }
37
+ }.freeze
38
+
39
+ # Create a new outbox message with the given attributes
40
+ # @param attributes [Hash] Message attributes
41
+ # @return [OutboxMessage] New message instance
42
+ def self.create(attributes = {})
43
+ new(attributes)
44
+ end
45
+
46
+ # Find a message by ID
47
+ # @param id [String, Integer] Message ID
48
+ # @return [OutboxMessage, nil] Message instance or nil if not found
49
+ def self.find(id)
50
+ raise NotImplementedError, "#{self.class.name}#find must be implemented by a framework adapter"
51
+ end
52
+
53
+ # Find messages by status
54
+ # @param status [String] Message status
55
+ # @param limit [Integer] Maximum number of messages to return
56
+ # @return [Array<OutboxMessage>] Array of message instances
57
+ def self.find_by_status(status, limit = 100)
58
+ raise NotImplementedError, "#{self.class.name}#find_by_status must be implemented by a framework adapter"
59
+ end
60
+
61
+ # Find messages ready for retry
62
+ # @param limit [Integer] Maximum number of messages to return
63
+ # @return [Array<OutboxMessage>] Array of message instances
64
+ def self.find_ready_for_retry(limit = 100)
65
+ raise NotImplementedError, "#{self.class.name}#find_ready_for_retry must be implemented by a framework adapter"
66
+ end
67
+
68
+ # Count messages by status
69
+ # @param status [String] Message status
70
+ # @return [Integer] Count of messages with the given status
71
+ def self.count_by_status(status)
72
+ raise NotImplementedError, "#{self.class.name}#count_by_status must be implemented by a framework adapter"
73
+ end
74
+
75
+ # Find the oldest message by status
76
+ # @param status [String] Message status
77
+ # @return [OutboxMessage, nil] Oldest message or nil if none found
78
+ def self.find_oldest_by_status(status)
79
+ raise NotImplementedError, "#{self.class.name}#find_oldest_by_status must be implemented by a framework adapter"
80
+ end
81
+
82
+ # Initialize a new outbox message
83
+ # @param attributes [Hash] Message attributes
84
+ def initialize(attributes = {})
85
+ @attributes = DEFAULTS.dup
86
+ attributes.each do |key, value|
87
+ send("#{key}=", value) if respond_to?("#{key}=")
88
+ end
89
+ end
90
+
91
+ # Get an attribute value
92
+ # @param name [Symbol] Attribute name
93
+ # @return [Object] Attribute value
94
+ def [](name)
95
+ @attributes[name.to_sym]
96
+ end
97
+
98
+ # Set an attribute value
99
+ # @param name [Symbol] Attribute name
100
+ # @param value [Object] Attribute value
101
+ def []=(name, value)
102
+ @attributes[name.to_sym] = value
103
+ end
104
+
105
+ # Get all attributes
106
+ # @return [Hash] All attributes
107
+ def attributes
108
+ @attributes.dup
109
+ end
110
+
111
+ # Save the message
112
+ # @return [Boolean] Whether the save was successful
113
+ def save
114
+ raise NotImplementedError, "#{self.class.name}#save must be implemented by a framework adapter"
115
+ end
116
+
117
+ # Update the message attributes
118
+ # @param attributes [Hash] New attribute values
119
+ # @return [Boolean] Whether the update was successful
120
+ def update(attributes = {})
121
+ attributes.each do |key, value|
122
+ send("#{key}=", value) if respond_to?("#{key}=")
123
+ end
124
+ save
125
+ end
126
+
127
+ # Mark the message as published
128
+ # @return [Boolean] Whether the update was successful
129
+ def mark_as_published
130
+ self.status = "published"
131
+ self.published_at = Time.now
132
+ self.updated_at = Time.now
133
+ save
134
+ end
135
+
136
+ # Mark the message as failed
137
+ # @param error [Exception, String] Error that caused the failure
138
+ # @return [Boolean] Whether the update was successful
139
+ def mark_as_failed(error = nil)
140
+ self.status = "failed"
141
+ self.error_message = error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
142
+ self.updated_at = Time.now
143
+ save
144
+ end
145
+
146
+ # Increment the retry count and set the next retry time
147
+ # @return [Boolean] Whether the update was successful
148
+ def increment_retry_count
149
+ self.retry_count += 1
150
+ self.next_retry_at = calculate_next_retry_time
151
+ self.updated_at = Time.now
152
+ save
153
+ end
154
+
155
+ # Calculate the next retry time based on exponential backoff
156
+ # @return [Time] Next retry time
157
+ def calculate_next_retry_time
158
+ base_delay = Pigeon.config.retry_delay || 30 # 30 seconds default
159
+ max_delay = Pigeon.config.max_retry_delay || 86_400 # 24 hours default
160
+
161
+ # Exponential backoff: delay = base_delay * (2 ^ retry_count)
162
+ delay = base_delay * (2**retry_count)
163
+ delay = [delay, max_delay].min # Cap at max delay
164
+
165
+ Time.now + delay
166
+ end
167
+
168
+ # Check if the message has exceeded the maximum retry count
169
+ # @return [Boolean] Whether the message has exceeded the maximum retry count
170
+ def max_retries_exceeded?
171
+ max_retries = self[:max_retries] || Pigeon.config.max_retries || 10
172
+ retry_count >= max_retries
173
+ end
174
+
175
+ # Define attribute accessors for all attributes
176
+ ATTRIBUTES.each do |attr|
177
+ define_method(attr) { @attributes[attr] }
178
+ define_method("#{attr}=") { |value| @attributes[attr] = value }
179
+ end
180
+ end
181
+ end
182
+ end