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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Gemfile.lock +3 -3
  4. data/README.md +104 -9
  5. data/bin/web-demo +592 -0
  6. data/docs/brand/BRAND.md +67 -0
  7. data/docs/brand/apple-touch-icon.png +0 -0
  8. data/docs/brand/favicon-16.png +0 -0
  9. data/docs/brand/favicon-32.png +0 -0
  10. data/docs/brand/favicon.ico +0 -0
  11. data/docs/brand/favicon.svg +1 -0
  12. data/docs/brand/icon-512.png +0 -0
  13. data/docs/brand/lockup-lepus-mono.svg +1 -0
  14. data/docs/brand/lockup-lepus-on-dark.svg +1 -0
  15. data/docs/brand/lockup-lepus-on-light.svg +1 -0
  16. data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
  17. data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
  18. data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
  19. data/docs/brand/lockup-lepus-saga.svg +1 -0
  20. data/docs/brand/lockup-lepus.svg +1 -0
  21. data/docs/brand/mark-mono.svg +1 -0
  22. data/docs/brand/mark-on-dark.svg +1 -0
  23. data/docs/brand/mark-on-light.svg +1 -0
  24. data/docs/brand/mark-small-mono.svg +1 -0
  25. data/docs/brand/mark-small-on-dark.svg +1 -0
  26. data/docs/brand/mark-small-on-light.svg +1 -0
  27. data/docs/brand/mark-small.svg +1 -0
  28. data/docs/brand/mark.svg +1 -0
  29. data/docs/configuration.md +1 -0
  30. data/docs/consumers.md +39 -5
  31. data/docs/getting-started.md +1 -1
  32. data/docs/images/lepus-web.png +0 -0
  33. data/docs/middleware.md +1 -1
  34. data/docs/producers.md +41 -0
  35. data/docs/testing.md +67 -48
  36. data/docs/web.md +39 -13
  37. data/gemfiles/Gemfile.rails-5.2.lock +2 -2
  38. data/gemfiles/Gemfile.rails-6.1.lock +4 -4
  39. data/gemfiles/Gemfile.rails-7.2.lock +1 -1
  40. data/gemfiles/Gemfile.rails-8.0.lock +1 -1
  41. data/lib/lepus/configuration.rb +6 -0
  42. data/lib/lepus/consumer.rb +34 -0
  43. data/lib/lepus/consumers/config.rb +8 -7
  44. data/lib/lepus/consumers/stats.rb +15 -2
  45. data/lib/lepus/delay.rb +180 -0
  46. data/lib/lepus/primitive/string.rb +2 -12
  47. data/lib/lepus/producer.rb +6 -1
  48. data/lib/lepus/producers/definition.rb +3 -1
  49. data/lib/lepus/publisher.rb +29 -3
  50. data/lib/lepus/testing/rspec_matchers.rb +20 -1
  51. data/lib/lepus/testing.rb +2 -0
  52. data/lib/lepus/version.rb +1 -1
  53. data/lib/lepus/web/api.rb +45 -0
  54. data/lib/lepus/web/app.rb +19 -16
  55. data/lib/lepus/web/management_api.rb +125 -2
  56. data/lib/lepus/web/topology.rb +341 -0
  57. data/lib/lepus/web.rb +17 -18
  58. data/lib/lepus.rb +1 -0
  59. data/web/assets/app.css +862 -0
  60. data/web/assets/apple-touch-icon.png +0 -0
  61. data/web/assets/favicon-32.png +0 -0
  62. data/web/assets/favicon.svg +1 -0
  63. data/web/assets/fonts/overpass/OFL.txt +93 -0
  64. data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
  65. data/web/assets/js/api.js +27 -0
  66. data/web/assets/js/dom.js +95 -0
  67. data/web/assets/js/format.js +63 -0
  68. data/web/assets/js/live.js +74 -0
  69. data/web/assets/js/main.js +156 -0
  70. data/web/assets/js/map.js +606 -0
  71. data/web/assets/js/model.js +262 -0
  72. data/web/assets/js/rates.js +161 -0
  73. data/web/assets/js/theme.js +8 -0
  74. data/web/assets/js/views/connections.js +61 -0
  75. data/web/assets/js/views/overview.js +229 -0
  76. data/web/assets/js/views/processes.js +101 -0
  77. data/web/assets/js/views/queues.js +456 -0
  78. data/web/index.html +58 -153
  79. data/web/sw.js +14 -143
  80. metadata +47 -12
  81. data/test_offline.html +0 -189
  82. data/web/assets/css/styles.css +0 -635
  83. data/web/assets/js/app.js +0 -6
  84. data/web/assets/js/bootstrap.js +0 -20
  85. data/web/assets/js/controllers/connection_controller.js +0 -44
  86. data/web/assets/js/controllers/dashboard_controller.js +0 -499
  87. data/web/assets/js/controllers/queue_controller.js +0 -17
  88. data/web/assets/js/controllers/theme_controller.js +0 -31
  89. data/web/assets/js/offline-manager.js +0 -233
  90. data/web/assets/js/service-worker-manager.js +0 -65
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- lepus (0.2.2)
4
+ lepus (0.3.0)
5
5
  base64
6
6
  bunny
7
7
  concurrent-ruby
@@ -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.
@@ -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
- name = @retry_queue_opts[:name]
156
- name ||= "#{queue_name}.retry"
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
- name = @error_queue_opts[:name]
162
- name ||= "#{queue_name}.error"
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
 
@@ -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
- begin
4
- require "dry/inflector"
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
@@ -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
- publisher(connection).publish(msg.payload, **msg.to_publish_options)
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
@@ -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
- exchange.publish(payload, opts)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lepus
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
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