lepus 0.2.2 → 0.3.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 +25 -0
- data/Gemfile.lock +3 -3
- data/README.md +104 -9
- data/bin/web-demo +592 -0
- data/docs/brand/BRAND.md +67 -0
- data/docs/brand/apple-touch-icon.png +0 -0
- data/docs/brand/favicon-16.png +0 -0
- data/docs/brand/favicon-32.png +0 -0
- data/docs/brand/favicon.ico +0 -0
- data/docs/brand/favicon.svg +1 -0
- data/docs/brand/icon-512.png +0 -0
- data/docs/brand/lockup-lepus-mono.svg +1 -0
- data/docs/brand/lockup-lepus-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga.svg +1 -0
- data/docs/brand/lockup-lepus.svg +1 -0
- data/docs/brand/mark-mono.svg +1 -0
- data/docs/brand/mark-on-dark.svg +1 -0
- data/docs/brand/mark-on-light.svg +1 -0
- data/docs/brand/mark-small-mono.svg +1 -0
- data/docs/brand/mark-small-on-dark.svg +1 -0
- data/docs/brand/mark-small-on-light.svg +1 -0
- data/docs/brand/mark-small.svg +1 -0
- data/docs/brand/mark.svg +1 -0
- data/docs/configuration.md +1 -0
- data/docs/consumers.md +39 -5
- data/docs/getting-started.md +1 -1
- data/docs/images/lepus-web.png +0 -0
- data/docs/middleware.md +1 -1
- data/docs/producers.md +41 -0
- data/docs/testing.md +67 -48
- data/docs/web.md +39 -13
- data/gemfiles/Gemfile.rails-5.2.lock +2 -2
- data/gemfiles/Gemfile.rails-6.1.lock +4 -4
- data/gemfiles/Gemfile.rails-7.2.lock +1 -1
- data/gemfiles/Gemfile.rails-8.0.lock +1 -1
- data/lib/lepus/configuration.rb +6 -0
- data/lib/lepus/consumer.rb +34 -0
- data/lib/lepus/consumers/config.rb +8 -7
- data/lib/lepus/consumers/stats.rb +15 -2
- data/lib/lepus/delay.rb +180 -0
- data/lib/lepus/primitive/string.rb +2 -12
- data/lib/lepus/producer.rb +6 -1
- data/lib/lepus/producers/definition.rb +3 -1
- data/lib/lepus/publisher.rb +29 -3
- data/lib/lepus/testing/rspec_matchers.rb +20 -1
- data/lib/lepus/testing.rb +2 -0
- data/lib/lepus/version.rb +1 -1
- data/lib/lepus/web/api.rb +45 -0
- data/lib/lepus/web/app.rb +19 -16
- data/lib/lepus/web/management_api.rb +125 -2
- data/lib/lepus/web/topology.rb +341 -0
- data/lib/lepus/web.rb +17 -18
- data/lib/lepus.rb +1 -0
- data/web/assets/app.css +862 -0
- data/web/assets/apple-touch-icon.png +0 -0
- data/web/assets/favicon-32.png +0 -0
- data/web/assets/favicon.svg +1 -0
- data/web/assets/fonts/overpass/OFL.txt +93 -0
- data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
- data/web/assets/js/api.js +27 -0
- data/web/assets/js/dom.js +95 -0
- data/web/assets/js/format.js +63 -0
- data/web/assets/js/live.js +74 -0
- data/web/assets/js/main.js +156 -0
- data/web/assets/js/map.js +606 -0
- data/web/assets/js/model.js +262 -0
- data/web/assets/js/rates.js +161 -0
- data/web/assets/js/theme.js +8 -0
- data/web/assets/js/views/connections.js +61 -0
- data/web/assets/js/views/overview.js +229 -0
- data/web/assets/js/views/processes.js +101 -0
- data/web/assets/js/views/queues.js +456 -0
- data/web/index.html +58 -153
- data/web/sw.js +14 -143
- metadata +47 -12
- data/test_offline.html +0 -189
- data/web/assets/css/styles.css +0 -635
- data/web/assets/js/app.js +0 -6
- data/web/assets/js/bootstrap.js +0 -20
- data/web/assets/js/controllers/connection_controller.js +0 -44
- data/web/assets/js/controllers/dashboard_controller.js +0 -499
- data/web/assets/js/controllers/queue_controller.js +0 -17
- data/web/assets/js/controllers/theme_controller.js +0 -31
- data/web/assets/js/offline-manager.js +0 -233
- data/web/assets/js/service-worker-manager.js +0 -65
data/lib/lepus/configuration.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Lepus
|
|
|
9
9
|
DEFAULT_RECOVER_FROM_CONNECTION_CLOSE = true
|
|
10
10
|
DEFAULT_CONSUMERS_DIRECTORY = Pathname.new("app/consumers")
|
|
11
11
|
DEFAULT_CONNECTION = :default
|
|
12
|
+
DEFAULT_DELAY_QUEUE_EXPIRES = 3_600_000
|
|
12
13
|
|
|
13
14
|
# @return [String] the connection string for RabbitMQ.
|
|
14
15
|
attr_accessor :rabbitmq_url
|
|
@@ -60,6 +61,10 @@ module Lepus
|
|
|
60
61
|
# prometheus_exporter collector for delivery and publish latency.
|
|
61
62
|
attr_accessor :prometheus_buckets
|
|
62
63
|
|
|
64
|
+
# @return [Integer, nil] milliseconds an unused delay queue is kept beyond its delay before
|
|
65
|
+
# RabbitMQ deletes it (x-expires = delay + this value). nil disables x-expires.
|
|
66
|
+
attr_accessor :delay_queue_expires
|
|
67
|
+
|
|
63
68
|
DEFAULT_PROMETHEUS_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze
|
|
64
69
|
|
|
65
70
|
def initialize
|
|
@@ -75,6 +80,7 @@ module Lepus
|
|
|
75
80
|
@application_name = nil
|
|
76
81
|
@management_api_url = nil
|
|
77
82
|
@prometheus_buckets = DEFAULT_PROMETHEUS_BUCKETS
|
|
83
|
+
@delay_queue_expires = DEFAULT_DELAY_QUEUE_EXPIRES
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
# Builds the process registry backend based on configuration.
|
data/lib/lepus/consumer.rb
CHANGED
|
@@ -4,6 +4,8 @@ module Lepus
|
|
|
4
4
|
# The abstract base class for consumers processing messages from queues.
|
|
5
5
|
# @abstract Subclass and override {#work} to implement.
|
|
6
6
|
class Consumer
|
|
7
|
+
CURRENT_DELIVERY_KEY = :lepus_consumer_delivery
|
|
8
|
+
|
|
7
9
|
class << self
|
|
8
10
|
def abstract_class?
|
|
9
11
|
return @abstract_class == true if defined?(@abstract_class)
|
|
@@ -95,12 +97,16 @@ module Lepus
|
|
|
95
97
|
# @param [String] payload The payload of the received message.
|
|
96
98
|
# @raise [InvalidConsumerReturnError] if you return something other than +:ack+, +:reject+ or +:requeue+ from {#perform}.
|
|
97
99
|
def process_delivery(delivery_info, metadata, payload)
|
|
100
|
+
previous_delivery = Thread.current[CURRENT_DELIVERY_KEY]
|
|
98
101
|
message = Message.coerce(delivery_info, metadata, payload)
|
|
99
102
|
message.consumer_class = self.class
|
|
100
103
|
|
|
104
|
+
Thread.current[CURRENT_DELIVERY_KEY] = message
|
|
101
105
|
Lepus.instrument(:process_delivery, **delivery_attributes(delivery_info)) do |event|
|
|
102
106
|
event[:result] = deliver(message, delivery_info)
|
|
103
107
|
end
|
|
108
|
+
ensure
|
|
109
|
+
Thread.current[CURRENT_DELIVERY_KEY] = previous_delivery
|
|
104
110
|
end
|
|
105
111
|
|
|
106
112
|
# Returns whether the last delivery resulted in an error.
|
|
@@ -148,6 +154,34 @@ module Lepus
|
|
|
148
154
|
end
|
|
149
155
|
alias_method :nack, :nack!
|
|
150
156
|
|
|
157
|
+
# Re-delivers the message being processed to this consumer's queue after the given delay,
|
|
158
|
+
# and acks the current delivery. The redelivery arrives through the default exchange, so its
|
|
159
|
+
# routing key is the queue name, and its {Lepus::Delay::COUNT_HEADER} header is incremented.
|
|
160
|
+
#
|
|
161
|
+
# @param value [Integer, Float, #in_milliseconds] The delay in milliseconds.
|
|
162
|
+
# @return [:ack]
|
|
163
|
+
# @raise [Lepus::Error] when the consumer is configured with +delays+ that don't include the value.
|
|
164
|
+
def delay!(value)
|
|
165
|
+
message = Thread.current[CURRENT_DELIVERY_KEY]
|
|
166
|
+
raise Lepus::Error, "delay! must be called while processing a message" unless message
|
|
167
|
+
|
|
168
|
+
opts = message.to_publish_options
|
|
169
|
+
opts.delete(:expiration)
|
|
170
|
+
headers = opts[:headers] || {}
|
|
171
|
+
opts[:headers] = headers.merge(Delay::COUNT_HEADER => headers[Delay::COUNT_HEADER].to_i + 1)
|
|
172
|
+
opts[:routing_key] = self.class.config.queue_name
|
|
173
|
+
opts[:delay] = Delay.resolve(value, self.class.config.delay_values)
|
|
174
|
+
|
|
175
|
+
publisher = Lepus::Publisher.new(Delay::DEFAULT_EXCHANGE)
|
|
176
|
+
if (channel = instance_variable_get(:@_handler_channel))
|
|
177
|
+
publisher.channel_publish(channel, message.payload, **opts)
|
|
178
|
+
else
|
|
179
|
+
publisher.publish(message.payload, **opts)
|
|
180
|
+
end
|
|
181
|
+
ack!
|
|
182
|
+
end
|
|
183
|
+
alias_method :delay, :delay!
|
|
184
|
+
|
|
151
185
|
private
|
|
152
186
|
|
|
153
187
|
# Publishes a message using the consumer's own exchange configuration.
|
|
@@ -37,7 +37,7 @@ module Lepus
|
|
|
37
37
|
|
|
38
38
|
DEFAULT_ERROR_QUEUE_OPTIONS = DEFAULT_QUEUE_OPTIONS
|
|
39
39
|
|
|
40
|
-
attr_reader :options, :prefetch_count
|
|
40
|
+
attr_reader :options, :prefetch_count, :delay_values
|
|
41
41
|
|
|
42
42
|
def initialize(options = {})
|
|
43
43
|
opts = Lepus::Primitive::Hash.new(options).deep_symbolize_keys
|
|
@@ -67,6 +67,7 @@ module Lepus
|
|
|
67
67
|
@bind_opts[:routing_key] ||= routing_key
|
|
68
68
|
end
|
|
69
69
|
@prefetch_count = opts.key?(:prefetch) ? opts.delete(:prefetch) : DEFAULT_PREFETCH_COUNT
|
|
70
|
+
@delay_values = Lepus::Delay.normalize_all(opts.delete(:delays))
|
|
70
71
|
@options = opts
|
|
71
72
|
end
|
|
72
73
|
|
|
@@ -152,15 +153,15 @@ module Lepus
|
|
|
152
153
|
end
|
|
153
154
|
|
|
154
155
|
def retry_queue_name
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
name
|
|
156
|
+
return unless @retry_queue_opts
|
|
157
|
+
|
|
158
|
+
@retry_queue_opts[:name] || "#{queue_name}.retry"
|
|
158
159
|
end
|
|
159
160
|
|
|
160
161
|
def error_queue_name
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
name
|
|
162
|
+
return unless @error_queue_opts
|
|
163
|
+
|
|
164
|
+
@error_queue_opts[:name] || "#{queue_name}.error"
|
|
164
165
|
end
|
|
165
166
|
|
|
166
167
|
protected
|
|
@@ -5,7 +5,7 @@ require "concurrent"
|
|
|
5
5
|
module Lepus
|
|
6
6
|
module Consumers
|
|
7
7
|
# Thread-safe per-consumer-class statistics tracker.
|
|
8
|
-
# Uses atomic counters to track processed/rejected/errored message counts.
|
|
8
|
+
# Uses atomic counters to track processed/rejected/errored/delayed message counts.
|
|
9
9
|
class Stats
|
|
10
10
|
attr_reader :consumer_class
|
|
11
11
|
|
|
@@ -14,6 +14,7 @@ module Lepus
|
|
|
14
14
|
@processed = Concurrent::AtomicFixnum.new(0)
|
|
15
15
|
@rejected = Concurrent::AtomicFixnum.new(0)
|
|
16
16
|
@errored = Concurrent::AtomicFixnum.new(0)
|
|
17
|
+
@delayed = Concurrent::AtomicFixnum.new(0)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def record_processed
|
|
@@ -28,6 +29,10 @@ module Lepus
|
|
|
28
29
|
@errored.increment
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
def record_delayed
|
|
33
|
+
@delayed.increment
|
|
34
|
+
end
|
|
35
|
+
|
|
31
36
|
def processed
|
|
32
37
|
@processed.value
|
|
33
38
|
end
|
|
@@ -40,6 +45,10 @@ module Lepus
|
|
|
40
45
|
@errored.value
|
|
41
46
|
end
|
|
42
47
|
|
|
48
|
+
def delayed
|
|
49
|
+
@delayed.value
|
|
50
|
+
end
|
|
51
|
+
|
|
43
52
|
def to_h
|
|
44
53
|
config = consumer_class.config
|
|
45
54
|
{
|
|
@@ -47,10 +56,14 @@ module Lepus
|
|
|
47
56
|
exchange: config.exchange_name,
|
|
48
57
|
queue: config.queue_name,
|
|
49
58
|
route: extract_route(config),
|
|
59
|
+
retry_queue: (config.retry_queue_name if config.retry_queue_args),
|
|
60
|
+
error_queue: (config.error_queue_name if config.error_queue_args),
|
|
50
61
|
threads: config.worker_threads,
|
|
51
62
|
processed: @processed.value,
|
|
52
63
|
rejected: @rejected.value,
|
|
53
|
-
errored: @errored.value
|
|
64
|
+
errored: @errored.value,
|
|
65
|
+
delayed: @delayed.value,
|
|
66
|
+
delays: config.delay_values
|
|
54
67
|
}
|
|
55
68
|
end
|
|
56
69
|
|
data/lib/lepus/delay.rb
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
# Delayed delivery built on per-delay TTL queues that dead-letter back to their target,
|
|
5
|
+
# so it works without the rabbitmq-delayed-message-exchange plugin.
|
|
6
|
+
#
|
|
7
|
+
# Exchange delays: messages are published to a headers exchange named "<exchange>.delayed"
|
|
8
|
+
# with a {HEADER} header. Each delay value has its own "<exchange>.delayed.<ms>" queue bound by
|
|
9
|
+
# that header; the queue dead-letters into the target exchange keeping the original routing key.
|
|
10
|
+
#
|
|
11
|
+
# Queue delays: messages are published through the default exchange to "<queue>.delay.<ms>",
|
|
12
|
+
# which dead-letters straight into the target queue.
|
|
13
|
+
module Delay
|
|
14
|
+
HEADER = "lepus-delay"
|
|
15
|
+
COUNT_HEADER = "x-lepus-delayed-count"
|
|
16
|
+
DEFAULT_EXCHANGE = ""
|
|
17
|
+
REDECLARE_INTERVAL = 60
|
|
18
|
+
|
|
19
|
+
@declarations = {}
|
|
20
|
+
@known_queues = Set.new
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# @param value [Integer, Float, #in_milliseconds] The delay in milliseconds.
|
|
25
|
+
# @return [Integer] The delay in whole milliseconds.
|
|
26
|
+
# @raise [ArgumentError] when the value is not a positive number of milliseconds.
|
|
27
|
+
def normalize(value)
|
|
28
|
+
ms = case value
|
|
29
|
+
when Numeric then value
|
|
30
|
+
else value.respond_to?(:in_milliseconds) ? value.in_milliseconds : value
|
|
31
|
+
end
|
|
32
|
+
raise ArgumentError, "Invalid delay #{value.inspect}, expected milliseconds" unless finite_number?(ms)
|
|
33
|
+
|
|
34
|
+
ms = ms.round
|
|
35
|
+
raise ArgumentError, "Delay must be greater than zero, got #{value.inspect}" unless ms > 0
|
|
36
|
+
|
|
37
|
+
ms
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Normalizes the delay and checks it against the allowed delays, when given.
|
|
41
|
+
# @param allowed [Array<Integer>, nil] The only delays accepted, in milliseconds.
|
|
42
|
+
# @return [Integer] The delay in whole milliseconds.
|
|
43
|
+
# @raise [Lepus::Error] when the delay is not one of the allowed values.
|
|
44
|
+
def resolve(value, allowed = nil)
|
|
45
|
+
ms = normalize(value)
|
|
46
|
+
if allowed && !allowed.include?(ms)
|
|
47
|
+
raise Lepus::Error, "Delay #{ms}ms is not allowed, expected one of #{allowed.inspect} (delays option)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
ms
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Array<Integer>, nil] The normalized, sorted and unique delays, or nil when none are given.
|
|
54
|
+
def normalize_all(values)
|
|
55
|
+
return if values.nil?
|
|
56
|
+
|
|
57
|
+
Array(values).map { |value| normalize(value) }.uniq.sort.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def exchange_name_for(exchange)
|
|
61
|
+
"#{exchange}.delayed"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def exchange_queue_name_for(exchange, ms)
|
|
65
|
+
"#{exchange}.delayed.#{ms}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def queue_delay_name_for(queue, ms)
|
|
69
|
+
"#{queue}.delay.#{ms}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def queue_args_for_exchange(exchange, ms)
|
|
73
|
+
with_expires(ms,
|
|
74
|
+
"x-message-ttl" => ms,
|
|
75
|
+
"x-dead-letter-exchange" => exchange)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def queue_args_for_queue(queue, ms)
|
|
79
|
+
with_expires(ms,
|
|
80
|
+
"x-message-ttl" => ms,
|
|
81
|
+
"x-dead-letter-exchange" => DEFAULT_EXCHANGE,
|
|
82
|
+
"x-dead-letter-routing-key" => queue)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Declares the delayed headers exchange and the queue for the given delay.
|
|
86
|
+
# @return [String] The name of the delayed exchange to publish to.
|
|
87
|
+
def declare_for_exchange!(channel, exchange, ms)
|
|
88
|
+
delayed_exchange = exchange_name_for(exchange)
|
|
89
|
+
queue = exchange_queue_name_for(exchange, ms)
|
|
90
|
+
args = queue_args_for_exchange(exchange, ms)
|
|
91
|
+
|
|
92
|
+
declare(channel, queue, args) do |ch|
|
|
93
|
+
ch.exchange_declare(delayed_exchange, :headers, durable: true)
|
|
94
|
+
ch.queue_declare(queue, durable: true, arguments: args)
|
|
95
|
+
ch.queue_bind(queue, delayed_exchange, arguments: {"x-match" => "all", HEADER => ms})
|
|
96
|
+
end
|
|
97
|
+
delayed_exchange
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Declares the queue that delays deliveries to the given queue.
|
|
101
|
+
# @return [String] The name of the delay queue to publish to.
|
|
102
|
+
def declare_for_queue!(channel, queue, ms)
|
|
103
|
+
delay_queue = queue_delay_name_for(queue, ms)
|
|
104
|
+
args = queue_args_for_queue(queue, ms)
|
|
105
|
+
|
|
106
|
+
declare(channel, delay_queue, args) do |ch|
|
|
107
|
+
ch.queue_declare(delay_queue, durable: true, arguments: args)
|
|
108
|
+
end
|
|
109
|
+
delay_queue
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def reset!
|
|
113
|
+
mutex.synchronize do
|
|
114
|
+
declarations.clear
|
|
115
|
+
known_queues.clear
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def finite_number?(value)
|
|
122
|
+
return false unless value.is_a?(Numeric) && value.real?
|
|
123
|
+
|
|
124
|
+
!value.respond_to?(:finite?) || value.finite?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def with_expires(ms, args)
|
|
128
|
+
padding = Lepus.config.delay_queue_expires
|
|
129
|
+
return args unless padding
|
|
130
|
+
|
|
131
|
+
args.merge("x-expires" => ms + padding)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Declarations run on a short-lived channel so a failure does not close the caller's channel.
|
|
135
|
+
# RabbitMQ only counts declarations and consumers as queue usage for x-expires, and a message
|
|
136
|
+
# published at t must outlive t + delay while the queue only lives until the last declaration
|
|
137
|
+
# + delay + padding, so declarations are refreshed before half of the padding elapses.
|
|
138
|
+
# The refresh is also capped so a queue deleted behind our back is declared again soon.
|
|
139
|
+
def declare(channel, queue, args)
|
|
140
|
+
connection = channel.connection
|
|
141
|
+
key = [connection, queue, args]
|
|
142
|
+
return if declared?(key)
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
connection.with_channel { |ch| yield ch }
|
|
146
|
+
rescue Bunny::PreconditionFailed => e
|
|
147
|
+
raise Lepus::Error, "Cannot declare delay queue #{queue.inspect} with #{args.inspect}: #{e.message}. " \
|
|
148
|
+
"Delete the queue or keep its previous arguments."
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
first = mutex.synchronize do
|
|
152
|
+
declarations[key] = monotonic_now + redeclare_interval
|
|
153
|
+
known_queues.add?(queue)
|
|
154
|
+
end
|
|
155
|
+
Lepus.logger.info("[Lepus::Delay] Declared delay queue #{queue.inspect} (#{known_queues.size} in this process)") if first
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def declared?(key)
|
|
159
|
+
mutex.synchronize do
|
|
160
|
+
next false unless declarations.key?(key)
|
|
161
|
+
|
|
162
|
+
monotonic_now < declarations[key]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def redeclare_interval
|
|
167
|
+
padding = Lepus.config.delay_queue_expires
|
|
168
|
+
return REDECLARE_INTERVAL unless padding
|
|
169
|
+
|
|
170
|
+
[padding / 2000.0, REDECLARE_INTERVAL].min
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def monotonic_now
|
|
174
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
attr_reader :declarations, :known_queues, :mutex
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
rescue LoadError
|
|
6
|
-
# noop
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
begin
|
|
10
|
-
require "active_support/inflector"
|
|
11
|
-
rescue LoadError
|
|
12
|
-
# noop
|
|
13
|
-
end
|
|
14
|
-
|
|
3
|
+
# Uses dry-inflector or ActiveSupport only when the host app has already
|
|
4
|
+
# loaded one, so Lepus never pulls either in by itself.
|
|
15
5
|
module Lepus::Primitive
|
|
16
6
|
class String < ::String
|
|
17
7
|
def classify
|
data/lib/lepus/producer.rb
CHANGED
|
@@ -87,6 +87,7 @@ module Lepus
|
|
|
87
87
|
# @param connection [Symbol, String, nil] The named connection to publish through,
|
|
88
88
|
# overriding the one set in {.configure}.
|
|
89
89
|
# @param options [Hash] Additional publish options (routing_key, headers, etc.).
|
|
90
|
+
# @option options [Integer, #in_milliseconds] :delay Delivers the message after this many milliseconds.
|
|
90
91
|
# @return [void]
|
|
91
92
|
def publish(payload, connection: nil, **options)
|
|
92
93
|
if definition.nil?
|
|
@@ -99,6 +100,8 @@ module Lepus
|
|
|
99
100
|
return unless Producers.enabled?(self)
|
|
100
101
|
|
|
101
102
|
publish_opts = definition.publish_options.merge(options)
|
|
103
|
+
delay = publish_opts.delete(:delay)
|
|
104
|
+
delay = Delay.resolve(delay, definition.delay_values) if delay
|
|
102
105
|
message = build_message(payload, publish_opts)
|
|
103
106
|
combined_chain = MiddlewareChain.combine(
|
|
104
107
|
Lepus.config.producer_middleware_chain,
|
|
@@ -106,7 +109,9 @@ module Lepus
|
|
|
106
109
|
)
|
|
107
110
|
|
|
108
111
|
combined_chain.execute(message) do |msg|
|
|
109
|
-
|
|
112
|
+
opts = msg.to_publish_options
|
|
113
|
+
opts[:delay] = delay if delay
|
|
114
|
+
publisher(connection).publish(msg.payload, **opts)
|
|
110
115
|
end
|
|
111
116
|
end
|
|
112
117
|
|
|
@@ -4,7 +4,7 @@ module Lepus
|
|
|
4
4
|
module Producers
|
|
5
5
|
# Definition class for producer-specific settings
|
|
6
6
|
class Definition
|
|
7
|
-
attr_reader :publish_options, :connection
|
|
7
|
+
attr_reader :publish_options, :connection, :delay_values
|
|
8
8
|
|
|
9
9
|
def initialize(options = {})
|
|
10
10
|
opts = Lepus::Primitive::Hash.new(options).deep_symbolize_keys
|
|
@@ -18,6 +18,8 @@ module Lepus
|
|
|
18
18
|
|
|
19
19
|
@connection = (opts.delete(:connection) || Lepus::Configuration::DEFAULT_CONNECTION).to_sym
|
|
20
20
|
|
|
21
|
+
@delay_values = Lepus::Delay.normalize_all(opts.delete(:delays))
|
|
22
|
+
|
|
21
23
|
# Store any remaining options for future use
|
|
22
24
|
@options = opts
|
|
23
25
|
end
|
data/lib/lepus/publisher.rb
CHANGED
|
@@ -27,6 +27,11 @@ module Lepus
|
|
|
27
27
|
# @return [Symbol] the named connection this publisher uses
|
|
28
28
|
attr_reader :connection
|
|
29
29
|
|
|
30
|
+
# @param message [String, Hash] The message to publish.
|
|
31
|
+
# @param options [Hash] Publish options. Accepts +delay:+ in milliseconds (or anything responding
|
|
32
|
+
# to +in_milliseconds+) to deliver the message later. On the default exchange the +routing_key+
|
|
33
|
+
# is the queue that receives the delayed message.
|
|
34
|
+
# @return [void]
|
|
30
35
|
def publish(message, **options)
|
|
31
36
|
return unless Producers.exchange_enabled?(exchange_name)
|
|
32
37
|
|
|
@@ -41,14 +46,19 @@ module Lepus
|
|
|
41
46
|
# @param [String, Hash] message The message to publish.
|
|
42
47
|
# @param [Hash] options Additional options for the publish.
|
|
43
48
|
# @return [void]
|
|
44
|
-
def channel_publish(channel, message, **options)
|
|
49
|
+
def channel_publish(channel, message, delay: nil, **options)
|
|
45
50
|
raise ArgumentError, "channel is required" unless channel
|
|
46
51
|
return unless Producers.exchange_enabled?(exchange_name)
|
|
47
52
|
|
|
53
|
+
delay = Delay.normalize(delay) if delay
|
|
48
54
|
payload, opts = prepare_message(message, **options)
|
|
49
55
|
exchange = channel.exchange(exchange_name, exchange_options)
|
|
50
|
-
Lepus.instrument(:publish, exchange: exchange_name, routing_key: opts[:routing_key]) do
|
|
51
|
-
|
|
56
|
+
Lepus.instrument(:publish, exchange: exchange_name, routing_key: opts[:routing_key], delay: delay) do
|
|
57
|
+
if delay
|
|
58
|
+
delayed_publish(channel, payload, delay, opts)
|
|
59
|
+
else
|
|
60
|
+
exchange.publish(payload, opts)
|
|
61
|
+
end
|
|
52
62
|
end
|
|
53
63
|
end
|
|
54
64
|
|
|
@@ -56,6 +66,22 @@ module Lepus
|
|
|
56
66
|
|
|
57
67
|
attr_reader :exchange_name, :exchange_options
|
|
58
68
|
|
|
69
|
+
def delayed_publish(channel, payload, delay, opts)
|
|
70
|
+
opts = opts.dup
|
|
71
|
+
routing_key = opts.delete(:routing_key)
|
|
72
|
+
|
|
73
|
+
if exchange_name == Delay::DEFAULT_EXCHANGE
|
|
74
|
+
raise ArgumentError, "routing_key (the target queue) is required to delay through the default exchange" unless routing_key
|
|
75
|
+
|
|
76
|
+
delay_queue = Delay.declare_for_queue!(channel, routing_key, delay)
|
|
77
|
+
channel.basic_publish(payload, Delay::DEFAULT_EXCHANGE, delay_queue, opts)
|
|
78
|
+
else
|
|
79
|
+
delayed_exchange = Delay.declare_for_exchange!(channel, exchange_name, delay)
|
|
80
|
+
opts[:headers] = (opts[:headers] || {}).merge(Delay::HEADER => delay)
|
|
81
|
+
channel.basic_publish(payload, delayed_exchange, routing_key, opts)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
59
85
|
def prepare_message(message, **options)
|
|
60
86
|
opts = DEFAULT_PUBLISH_OPTIONS.merge(options)
|
|
61
87
|
payload = if message.is_a?(String)
|
|
@@ -10,6 +10,7 @@ module Lepus
|
|
|
10
10
|
@expected_exchange = nil
|
|
11
11
|
@expected_routing_key = nil
|
|
12
12
|
@expected_payload = nil
|
|
13
|
+
@expected_delay = nil
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
# Support block expectations
|
|
@@ -37,7 +38,7 @@ module Lepus
|
|
|
37
38
|
return false
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
matches_count? && matches_exchange? && matches_routing_key? && matches_payload?
|
|
41
|
+
matches_count? && matches_exchange? && matches_routing_key? && matches_payload? && matches_delay?
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
def failure_message
|
|
@@ -59,6 +60,11 @@ module Lepus
|
|
|
59
60
|
message += " with payload matching #{@expected_payload} (#{matching_payload} matched)"
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
if @expected_delay
|
|
64
|
+
matching_delay = get_recent_messages(@published_count).count { |msg| msg[:delay] == @expected_delay }
|
|
65
|
+
message += " with delay #{@expected_delay}ms (#{matching_delay} matched)"
|
|
66
|
+
end
|
|
67
|
+
|
|
62
68
|
message
|
|
63
69
|
end
|
|
64
70
|
|
|
@@ -86,6 +92,11 @@ module Lepus
|
|
|
86
92
|
self
|
|
87
93
|
end
|
|
88
94
|
|
|
95
|
+
def with_delay(delay)
|
|
96
|
+
@expected_delay = Lepus::Delay.normalize(delay)
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
89
100
|
private
|
|
90
101
|
|
|
91
102
|
def expected_description
|
|
@@ -93,6 +104,7 @@ module Lepus
|
|
|
93
104
|
desc += " to exchange '#{@expected_exchange}'" if @expected_exchange
|
|
94
105
|
desc += " with routing key '#{@expected_routing_key}'" if @expected_routing_key
|
|
95
106
|
desc += " with payload matching #{@expected_payload}" if @expected_payload
|
|
107
|
+
desc += " with delay #{@expected_delay}ms" if @expected_delay
|
|
96
108
|
desc
|
|
97
109
|
end
|
|
98
110
|
|
|
@@ -140,6 +152,13 @@ module Lepus
|
|
|
140
152
|
recent_messages.any? { |msg| payload_matcher.matches?(msg[:payload]) }
|
|
141
153
|
end
|
|
142
154
|
|
|
155
|
+
def matches_delay?
|
|
156
|
+
return true unless @expected_delay
|
|
157
|
+
return false if @published_count == 0
|
|
158
|
+
|
|
159
|
+
get_recent_messages(@published_count).any? { |msg| msg[:delay] == @expected_delay }
|
|
160
|
+
end
|
|
161
|
+
|
|
143
162
|
def count_all_messages
|
|
144
163
|
Lepus::Testing::Exchange.all.values.sum(&:size)
|
|
145
164
|
end
|
data/lib/lepus/testing.rb
CHANGED
|
@@ -194,6 +194,8 @@ module Lepus
|
|
|
194
194
|
payload: message, # Store the original message, not the JSON string
|
|
195
195
|
routing_key: opts[:routing_key],
|
|
196
196
|
headers: opts[:headers],
|
|
197
|
+
delay: opts[:delay] && Lepus::Delay.normalize(opts[:delay]),
|
|
198
|
+
expiration: opts[:expiration],
|
|
197
199
|
persistent: opts[:persistent],
|
|
198
200
|
mandatory: opts[:mandatory],
|
|
199
201
|
immediate: opts[:immediate],
|
data/lib/lepus/version.rb
CHANGED
data/lib/lepus/web/api.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module Lepus
|
|
4
4
|
module Web
|
|
5
5
|
class API
|
|
6
|
+
POLL_INTERVALS = [0, 2, 5, 15, 60].freeze
|
|
7
|
+
DEFAULT_POLL_INTERVAL = 5
|
|
8
|
+
HISTORY_AGES = {600 => 10, 3600 => 60}.freeze
|
|
9
|
+
|
|
6
10
|
def initialize(aggregator: nil, management_api: nil)
|
|
7
11
|
@aggregator = aggregator
|
|
8
12
|
@management_api = management_api
|
|
@@ -21,6 +25,12 @@ module Lepus
|
|
|
21
25
|
connections_data
|
|
22
26
|
when "/exchanges"
|
|
23
27
|
exchanges_data
|
|
28
|
+
when "/topology"
|
|
29
|
+
topology_data
|
|
30
|
+
when "/overview"
|
|
31
|
+
overview_data(req)
|
|
32
|
+
when "/meta"
|
|
33
|
+
meta_data
|
|
24
34
|
else
|
|
25
35
|
Web::RespondWith.json(template: :not_found)
|
|
26
36
|
end
|
|
@@ -83,6 +93,40 @@ module Lepus
|
|
|
83
93
|
Web::RespondWith.json(template: :ok, body: [])
|
|
84
94
|
end
|
|
85
95
|
|
|
96
|
+
def topology_data
|
|
97
|
+
topology = Topology.new(
|
|
98
|
+
management_api: management_api,
|
|
99
|
+
aggregator: aggregator,
|
|
100
|
+
show_all_exchanges: Lepus.config.web_show_all_exchanges
|
|
101
|
+
)
|
|
102
|
+
Web::RespondWith.json(template: :ok, body: topology.to_h)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def overview_data(req)
|
|
106
|
+
return Web::RespondWith.json(template: :ok, body: {management: false}) unless management_api
|
|
107
|
+
|
|
108
|
+
age = req.params["age"].to_i
|
|
109
|
+
age = HISTORY_AGES.keys.first unless HISTORY_AGES.key?(age)
|
|
110
|
+
payload = management_api.overview(age: age, increment: HISTORY_AGES[age])
|
|
111
|
+
Web::RespondWith.json(template: :ok, body: payload.merge(management: true, age: age))
|
|
112
|
+
rescue => e
|
|
113
|
+
Lepus.logger.warn("[Web::API] Failed to fetch overview: #{e.message}")
|
|
114
|
+
Web::RespondWith.json(template: :ok, body: {management: false, error: ManagementAPI.client_message(e)})
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def meta_data
|
|
118
|
+
config = Lepus.config
|
|
119
|
+
Web::RespondWith.json(template: :ok, body: {
|
|
120
|
+
version: Lepus::VERSION,
|
|
121
|
+
heartbeat_interval: config.process_heartbeat_interval,
|
|
122
|
+
alive_threshold: config.process_alive_threshold,
|
|
123
|
+
poll_interval: DEFAULT_POLL_INTERVAL,
|
|
124
|
+
poll_intervals: POLL_INTERVALS,
|
|
125
|
+
history_ages: HISTORY_AGES.keys,
|
|
126
|
+
show_all_exchanges: config.web_show_all_exchanges ? true : false
|
|
127
|
+
})
|
|
128
|
+
end
|
|
129
|
+
|
|
86
130
|
def annotate_queues_with_apps(queues)
|
|
87
131
|
return queues unless aggregator&.running?
|
|
88
132
|
|
|
@@ -102,6 +146,7 @@ module Lepus
|
|
|
102
146
|
known_exchanges = build_known_exchange_names
|
|
103
147
|
return exchanges if known_exchanges.empty?
|
|
104
148
|
|
|
149
|
+
known_exchanges += known_exchanges.map { |name| Delay.exchange_name_for(name) }
|
|
105
150
|
exchanges.select { |e| known_exchanges.include?(e[:name]) }
|
|
106
151
|
end
|
|
107
152
|
|