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.
- checksums.yaml +7 -0
- data/README.md +343 -0
- data/lib/pigeon/active_job_integration.rb +32 -0
- data/lib/pigeon/api.rb +200 -0
- data/lib/pigeon/configuration.rb +161 -0
- data/lib/pigeon/core.rb +104 -0
- data/lib/pigeon/encryption.rb +213 -0
- data/lib/pigeon/generators/hanami/migration_generator.rb +89 -0
- data/lib/pigeon/generators/rails/install_generator.rb +32 -0
- data/lib/pigeon/generators/rails/migration_generator.rb +20 -0
- data/lib/pigeon/generators/rails/templates/create_outbox_messages.rb.erb +34 -0
- data/lib/pigeon/generators/rails/templates/initializer.rb.erb +88 -0
- data/lib/pigeon/hanami_integration.rb +78 -0
- data/lib/pigeon/health_check/kafka.rb +37 -0
- data/lib/pigeon/health_check/processor.rb +70 -0
- data/lib/pigeon/health_check/queue.rb +69 -0
- data/lib/pigeon/health_check.rb +63 -0
- data/lib/pigeon/logging/structured_logger.rb +181 -0
- data/lib/pigeon/metrics/collector.rb +200 -0
- data/lib/pigeon/mock_producer.rb +18 -0
- data/lib/pigeon/models/adapters/active_record_adapter.rb +133 -0
- data/lib/pigeon/models/adapters/rom_adapter.rb +150 -0
- data/lib/pigeon/models/outbox_message.rb +182 -0
- data/lib/pigeon/monitoring.rb +113 -0
- data/lib/pigeon/outbox.rb +61 -0
- data/lib/pigeon/processor/background_processor.rb +109 -0
- data/lib/pigeon/processor.rb +798 -0
- data/lib/pigeon/publisher.rb +524 -0
- data/lib/pigeon/railtie.rb +29 -0
- data/lib/pigeon/schema.rb +35 -0
- data/lib/pigeon/security.rb +30 -0
- data/lib/pigeon/serializer.rb +77 -0
- data/lib/pigeon/tasks/pigeon.rake +64 -0
- data/lib/pigeon/trace_api.rb +37 -0
- data/lib/pigeon/tracing/core.rb +119 -0
- data/lib/pigeon/tracing/messaging.rb +144 -0
- data/lib/pigeon/tracing.rb +107 -0
- data/lib/pigeon/version.rb +5 -0
- data/lib/pigeon.rb +52 -0
- 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
|