hutch 1.3.1 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02b687d56ec0d56dc49c07722e3e55c3b86ca274008394209b3fe60d6ecb8bf4
4
- data.tar.gz: a6c38c9b5f26944f7abb3b078d4cf693bb29ed2fb08987b7f4bc5e4fe2a70f78
3
+ metadata.gz: d9ca80c1f8e42d188755ed5bc94834a841a3935f962e977f3fe01bc17753bdf7
4
+ data.tar.gz: '0149d00db0ebe07503caf6fd955a051cbc71581e2190a44f91c1128a9ebc3744'
5
5
  SHA512:
6
- metadata.gz: 85513bc753214f4a077c4a50510519082e3f5bc8aee75cba1df09461be9723e4a8d438a97f3c6bd3025fb59a0d7008adb5577c7faee082367ddd85f7239f748a
7
- data.tar.gz: f0d6fcc77c94a0349104243c3daac3e2f4fe057ce96a4b251c0577969fcaf1b69860ca3eadd163aac353275bbe517847573f6064ea46cad70aa8979004c6e9f7
6
+ metadata.gz: 5f1432e36ee499275c334b3f39268f2907ab842e766cc0dded754abeaca771692e7a11be8ef3d48c3c2f6e6a6ba1690de5ac5381db5dd0d1cde00cb1f65d7ffa
7
+ data.tar.gz: 16d5ef22b69e7468e097a2dacb49b736d27245e586767e013763f577bf69918f0b44f507ddc59c90ba116e9d473eeba3a5acdd8a95cb4b7407d5c899cadc0ebb
@@ -1,50 +1,68 @@
1
- name: Test
1
+ name: CI
2
2
 
3
3
  concurrency:
4
4
  group: ${{ github.ref }}
5
5
  cancel-in-progress: true
6
6
 
7
- on: [push,pull_request,workflow_dispatch]
7
+ on:
8
+ push:
9
+ branches:
10
+ - "main"
11
+ pull_request:
12
+ branches:
13
+ - "main"
14
+ workflow_dispatch:
8
15
 
9
16
  jobs:
10
17
  test:
11
-
18
+ name: RabbitMQ ${{ matrix.rabbitmq }}, Ruby ${{ matrix.ruby }}
12
19
  runs-on: ubuntu-latest
13
20
 
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ ruby:
25
+ - "4.0"
26
+ - "3.4"
27
+ - "3.3"
28
+ - "3.2"
29
+ - "3.1"
30
+ - "3.0"
31
+ rabbitmq:
32
+ - "4.2"
33
+ - "4.1"
34
+ - "4.0"
35
+ - "3.13"
36
+
37
+ env:
38
+ CI: true
39
+
14
40
  services:
15
41
  rabbitmq:
16
- image: rabbitmq:3-management
42
+ image: rabbitmq:${{ matrix.rabbitmq }}-management
17
43
  ports:
18
44
  - 5672:5672
19
45
  - 15672:15672
20
46
  options: --name rabbitmq
21
47
 
22
- strategy:
23
- fail-fast: false
24
- matrix:
25
- ruby-version:
26
- - '2.7'
27
- - '3.0'
28
- - '3.1'
29
- - '3.2'
30
- - '3.3'
31
-
32
48
  steps:
33
- - uses: actions/checkout@v4
34
- - name: Set up Ruby
35
- # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
36
- # change this to (see https://github.com/ruby/setup-ruby#versioning):
37
- uses: ruby/setup-ruby@v1
38
- # uses: ruby/setup-ruby@473e4d8fe5dd94ee328fdfca9f8c9c7afc9dae5e
39
- with:
40
- ruby-version: ${{ matrix.ruby-version }}
41
- bundler-cache: true # runs 'bundle install' and caches installed gems automatically
42
- - name: Set up RabbitMQ
43
- run: |
44
- until sudo lsof -i:5672; do echo "Waiting for RabbitMQ to start..."; sleep 1; done
45
- ./bin/ci/before_build_docker.sh
46
- - name: Run tests
47
- run: bundle exec rspec spec
49
+ - uses: actions/checkout@v4
50
+
51
+ - name: Set up Ruby ${{ matrix.ruby }}
52
+ uses: ruby/setup-ruby@v1
53
+ with:
54
+ ruby-version: ${{ matrix.ruby }}
55
+ bundler-cache: true
56
+
57
+ - name: Wait for RabbitMQ to start
58
+ run: sleep 10
59
+
60
+ - name: Set up RabbitMQ
61
+ run: ./bin/ci/before_build_docker.sh
62
+
63
+ - name: Run tests
64
+ run: bundle exec rspec spec
65
+
48
66
  test_all:
49
67
  if: ${{ always() }}
50
68
  runs-on: ubuntu-latest
data/AGENTS.md ADDED
@@ -0,0 +1,127 @@
1
+ # Instructions for AI Agents
2
+
3
+ ## Overview
4
+
5
+ Hutch is a Ruby library for enabling asynchronous inter-service communication
6
+ using RabbitMQ. It provides a conventions-based framework for consumers and
7
+ producers using topic exchanges.
8
+
9
+ Its key dependencies are [Bunny](https://github.com/ruby-amqp/bunny) (MRI Ruby)
10
+ or [March Hare](https://github.com/ruby-amqp/march_hare) (JRuby), two RabbitMQ clients that use AMQP 0-9-1.
11
+
12
+ On top, Hutch uses [carrot-top](https://github.com/change/carrot-top) for the RabbitMQ HTTP API,
13
+ and [ActiveSupport](https://github.com/rails/rails/tree/main/activesupport) from Ruby on Rails.
14
+
15
+ ## Target Ruby Version
16
+
17
+ This library targets Ruby 3.0 and later versions
18
+ For JRuby, the supported series are 9.x and 10.x.
19
+
20
+ ## Build and Test
21
+
22
+ ```bash
23
+ bundle install
24
+
25
+ bundle exec rspec spec
26
+ ```
27
+
28
+ To run tests use `rspec` directly (not Rake).
29
+
30
+ ## Key Files
31
+
32
+ ### Core
33
+
34
+ * `lib/hutch.rb`: top-level module, global consumer registry, a connection singleton
35
+ * `lib/hutch/broker.rb`: RabbitMQ connection and channel lifecycle, topology declaration, [delivery acknowledgements](https://www.rabbitmq.com/docs/confirms), [publisher confirms](https://www.rabbitmq.com/docs/confirms), [TLS](https://www.rabbitmq.com/docs/ssl), HTTP API client
36
+ * `lib/hutch/worker.rb`: the main consumer loop: topology and consumer setup, delivery dispatch, error handling
37
+ * `lib/hutch/consumer.rb`: `Hutch::Consumer` module included by consumer classes; DSL: `consume`, `queue_name`, `lazy_queue`, `quorum_queue`, `arguments`, `queue_options`, `serializer`
38
+ * `lib/hutch/publisher.rb`: message serialization, routing, publisher confirms
39
+ * `lib/hutch/message.rb`: message wrapper (delivery_info, properties, payload)
40
+ * `lib/hutch/config.rb`: configuration with 3-tier precedence (defaults < ENV `HUTCH_*` < config file < explicit set)
41
+ * `lib/hutch/cli.rb`: CLI (based on `OptionParser`), Rails app detection, consumer loading, daemonization
42
+ * `lib/hutch/exceptions.rb`: `ConnectionError`, `AuthenticationError`, `WorkerSetupError`, `PublishError`
43
+ * `lib/hutch/logging.rb`: configurable logger with `HutchFormatter`
44
+ * `lib/hutch/waiter.rb`: signal handling (`SIGINT`, `SIGTERM`, `SIGQUIT` for shutdown; `SIGUSR2` for thread dumps)
45
+ * `lib/hutch/version.rb`: the `Hutch::VERSION` constant
46
+
47
+ ### Adapters
48
+
49
+ * `lib/hutch/adapter.rb`: runtime adapter selector (Bunny on MRI, March Hare on JRuby)
50
+ * `lib/hutch/adapters/bunny.rb`: Bunny adapter
51
+ * `lib/hutch/adapters/march_hare.rb`: March Hare adapter
52
+
53
+ ### Serializers
54
+
55
+ * `lib/hutch/serializers/json.rb`: JSON serialization via `multi_json`
56
+ * `lib/hutch/serializers/identity.rb`: pass-through (no-op) serializer
57
+
58
+ ### Error Handlers
59
+
60
+ * `lib/hutch/error_handlers/base.rb`: base class
61
+ * `lib/hutch/error_handlers/logger.rb`: logs errors (default)
62
+ * `lib/hutch/error_handlers/sentry.rb`: sentry-ruby integration
63
+ * `lib/hutch/error_handlers/sentry_raven.rb`: legacy sentry-raven integration
64
+ * `lib/hutch/error_handlers/honeybadger.rb`: Honeybadger
65
+ * `lib/hutch/error_handlers/airbrake.rb`: Airbrake
66
+ * `lib/hutch/error_handlers/rollbar.rb`: Rollbar
67
+ * `lib/hutch/error_handlers/bugsnag.rb`: Bugsnag
68
+
69
+ ### Tracers
70
+
71
+ * `lib/hutch/tracers/null_tracer.rb`: no-op (default)
72
+ * `lib/hutch/tracers/newrelic.rb`: NewRelic APM
73
+ * `lib/hutch/tracers/datadog.rb`: Datadog tracing
74
+
75
+ ### Acknowledgement Strategies
76
+
77
+ * `lib/hutch/acknowledgements/base.rb`: base interface (a chain of responsibility)
78
+ * `lib/hutch/acknowledgements/nack_on_all_failures.rb`: the default fallback
79
+
80
+ ## Test Suite
81
+
82
+ The test suite uses RSpec:
83
+
84
+ ```bash
85
+ bundle exec rspec spec
86
+ ```
87
+
88
+ Test files mirror the `lib/hutch/` structure under `spec/hutch/`. Tests are filtered
89
+ by adapter at runtime: Bunny specs are excluded on JRuby, March Hare specs on MRI.
90
+
91
+ ## Comments
92
+
93
+ * Only add important comments that express the non-obvious intent, both in tests and in the implementation
94
+ * Keep the comments short
95
+ * Pay attention to the grammar of your comments, including punctuation, full stops, articles, and so on
96
+
97
+ ## Change Log
98
+
99
+ If asked to perform change log updates, consult and modify `CHANGELOG.md` and stick to its
100
+ existing writing style.
101
+
102
+ ## Releases
103
+
104
+ ### How to Roll (Produce) a New Release
105
+
106
+ Suppose the current development version in `CHANGELOG.md` has
107
+ a `## X.Y.0 (in development)` section at the top.
108
+
109
+ To produce a new release:
110
+
111
+ 1. Update `CHANGELOG.md`: replace `(in development)` with today's date, e.g. `(Mar 30, 2026)`. Make sure all notable changes since the previous release are listed
112
+ 2. Update the version in `lib/hutch/version.rb` to match (remove the `.pre` suffix)
113
+ 3. Commit with the message `X.Y.0` (just the version number, nothing else)
114
+ 4. Tag the commit: `git tag vX.Y.0`
115
+ 5. Bump the dev version: add a new `## X.(Y+1).0 (in development)` section to `CHANGELOG.md` with `No changes yet.` underneath, and update `lib/hutch/version.rb` to the next dev version with a `.pre` suffix
116
+ 6. Commit with the message `Bump dev version`
117
+ 7. Push: `git push && git push origin vX.(Y+1).0`
118
+
119
+ ## Git Instructions
120
+
121
+ * Do not commit changes automatically without an explicit permission to do so
122
+ * Never add yourself to the list of commit co-authors
123
+ * Never mention yourself in commit messages in any way (no "Generated by", no AI tool links, etc)
124
+
125
+ ## Style Guide
126
+
127
+ * Never add full stops to Markdown list items
data/CHANGELOG.md CHANGED
@@ -1,8 +1,51 @@
1
- ## 1.3.2 (in development)
1
+ ## 1.4.0 (Apr 7, 2026)
2
2
 
3
- No changes yet.
3
+ ### Ruby 3.0 is Now Required
4
4
 
5
- ## 1.3.1 (Dec 11, 2024)
5
+ This version adopts [Bunny `3.x`](https://github.com/ruby-amqp/bunny/releases/tag/3.0.0)
6
+ and as a result, requires Ruby 3.0.
7
+
8
+ ### `without_namespace` Consumer DSL Option
9
+
10
+ Consumers can now opt out of the automatic namespace prefix
11
+ for their queue name using the `without_namespace` DSL method:
12
+
13
+ ``` ruby
14
+ class DeadLetterConsumer
15
+ include Hutch::Consumer
16
+ consume 'dead.letters'
17
+ queue_name 'deadletter'
18
+ without_namespace
19
+ end
20
+ ```
21
+
22
+ Originally contributed by @tlloydthwaites.
23
+
24
+ GitHub issue: [#393](https://github.com/ruby-amqp/hutch/pull/393)
25
+
26
+ ### Consumer Channel Recovery After Delivery Acknowledgement Timeout
27
+
28
+ Hutch now automatically "recovers" (reopens) consumer channels closed by RabbitMQ
29
+ due to a [delivery acknowledgement timeout](https://www.rabbitmq.com/docs/consumers#acknowledgement-timeout).
30
+
31
+ Note that this exception indicates a potential issue on Hutch's end:
32
+ a delivery was not acknowledged in time (30 minutes by default). Even then,
33
+ recovering the channel in this specific scenario is an operational improvement.
34
+
35
+ Contributed by @Garaio-REM.
36
+
37
+ GitHub issue: [#414](https://github.com/ruby-amqp/hutch/pull/414)
38
+
39
+ ### Migrated Datadog Tracer From the `ddtrace` Gem to `datadog`
40
+
41
+ The Datadog tracer now uses the `datadog` gem instead of the deprecated `ddtrace`.
42
+ The `ddtrace` gem is still supported but emits a deprecation warning at load time.
43
+ Support for `ddtrace` will be removed in Hutch 2.0.
44
+
45
+ ### Deprecated `SentryRaven` Error Handler
46
+
47
+ `Hutch::ErrorHandlers::SentryRaven` now emits a deprecation warning and will be
48
+ removed in Hutch 2.0. Use `Hutch::ErrorHandlers::Sentry` (backed by `sentry-ruby`) instead.
6
49
 
7
50
  ### Rails 8.x Compatibility
8
51
 
@@ -19,7 +62,7 @@ GitHub issue: [#392](https://github.com/ruby-amqp/hutch/pull/392)
19
62
 
20
63
  ### Relaxed ActiveSupport Dependency Constraints
21
64
 
22
- Contributed by @drobny.
65
+ Contributed by drobny.
23
66
 
24
67
  GitHub issue: [#402](https://github.com/ruby-amqp/hutch/pull/402)
25
68
 
@@ -93,7 +136,7 @@ contains potentially breaking changes.
93
136
 
94
137
  This means **some defaults introduced in `0.28.0` ([gocardless/hutch#341](https://github.com/gocardless/hutch/pull/341)) were reverted**.
95
138
  The user has to opt in to configure the queue type and mode and other [optional arguments](https://www.rabbitmq.com/queues.html#optional-arguments) they need to use.
96
- Most optional arguments can be set via [policies](https://www.rabbitmq.com/parameters.html#policies) which is always the recommended approach.
139
+ Most optional arguments can be set via [policies](https://www.rabbitmq.com/parameters.html#policies) which is always the recommended approach.
97
140
  Queue type, unfortunately, is not one of them as different queue types have completely different
98
141
  implementation details, on disk data formats and so on.
99
142
 
@@ -106,7 +149,7 @@ contains potentially breaking changes.
106
149
  # when in doubt, prefer using a policy to this DSL
107
150
  # https://www.rabbitmq.com/parameters.html#policies
108
151
  arguments 'x-key': :value
109
-
152
+
110
153
  quorum_queue
111
154
  end
112
155
  ```
@@ -120,7 +163,7 @@ contains potentially breaking changes.
120
163
  # when in doubt, prefer using a policy to this DSL
121
164
  # https://www.rabbitmq.com/parameters.html#policies
122
165
  arguments 'x-key': :value
123
-
166
+
124
167
  lazy_queue
125
168
  classic_queue
126
169
  end
data/Gemfile CHANGED
@@ -1,6 +1,6 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
- ruby '>= 2.7.0'
3
+ ruby '>= 3.0.0'
4
4
 
5
5
  gemspec
6
6
 
@@ -23,7 +23,7 @@ group :development, :test do
23
23
  gem "sentry-ruby"
24
24
  gem "honeybadger"
25
25
  gem "newrelic_rpm"
26
- gem "ddtrace", "~> 1.8"
26
+ gem "datadog"
27
27
  gem "airbrake", "~> 13.0"
28
28
  gem "rollbar"
29
29
  gem "bugsnag"
@@ -1,11 +1,6 @@
1
1
  #!/bin/sh
2
2
 
3
3
  CTL=${MARCH_HARE_RABBITMQCTL:="docker exec rabbitmq rabbitmqctl"}
4
- PLUGINS=${MARCH_HARE_RABBITMQ_PLUGINS:="docker exec rabbitmq rabbitmq-plugins"}
5
-
6
- $PLUGINS enable rabbitmq_management
7
-
8
- sleep 3
9
4
 
10
5
  # guest:guest has full access to /
11
6
 
@@ -14,7 +9,7 @@ $CTL add_vhost /
14
9
  $CTL set_permissions -p / guest ".*" ".*" ".*"
15
10
 
16
11
  # Reduce retention policy for faster publishing of stats
17
- $CTL eval 'supervisor2:terminate_child(rabbit_mgmt_sup_sup, rabbit_mgmt_sup), application:set_env(rabbitmq_management, sample_retention_policies, [{global, [{605, 1}]}, {basic, [{605, 1}]}, {detailed, [{10, 1}]}]), rabbit_mgmt_sup_sup:start_child().'
18
- $CTL eval 'supervisor2:terminate_child(rabbit_mgmt_agent_sup_sup, rabbit_mgmt_agent_sup), application:set_env(rabbitmq_management_agent, sample_retention_policies, [{global, [{605, 1}]}, {basic, [{605, 1}]}, {detailed, [{10, 1}]}]), rabbit_mgmt_agent_sup_sup:start_child().'
12
+ $CTL eval 'supervisor2:terminate_child(rabbit_mgmt_sup_sup, rabbit_mgmt_sup), application:set_env(rabbitmq_management, sample_retention_policies, [{global, [{605, 1}]}, {basic, [{605, 1}]}, {detailed, [{10, 1}]}]), rabbit_mgmt_sup_sup:start_child().' || true
13
+ $CTL eval 'supervisor2:terminate_child(rabbit_mgmt_agent_sup_sup, rabbit_mgmt_agent_sup), application:set_env(rabbitmq_management_agent, sample_retention_policies, [{global, [{605, 1}]}, {basic, [{605, 1}]}, {detailed, [{10, 1}]}]), rabbit_mgmt_agent_sup_sup:start_child().' || true
19
14
 
20
15
  sleep 3
data/hutch.gemspec CHANGED
@@ -3,10 +3,10 @@ require File.expand_path('../lib/hutch/version', __FILE__)
3
3
  Gem::Specification.new do |gem|
4
4
  if defined?(JRUBY_VERSION)
5
5
  gem.platform = 'java'
6
- gem.add_runtime_dependency 'march_hare', '>= 4.5.0'
6
+ gem.add_runtime_dependency 'march_hare', '>= 4.7.0'
7
7
  else
8
8
  gem.platform = Gem::Platform::RUBY
9
- gem.add_runtime_dependency 'bunny', '>= 2.23', '< 3.0'
9
+ gem.add_runtime_dependency 'bunny', '>= 3.1', '< 4.0'
10
10
  end
11
11
  gem.add_runtime_dependency 'carrot-top', '~> 0.0.7'
12
12
  gem.add_runtime_dependency 'multi_json', '~> 1.15'
@@ -16,7 +16,7 @@ Gem::Specification.new do |gem|
16
16
  gem.summary = 'Opinionated asynchronous inter-service communication using RabbitMQ'
17
17
  gem.description = 'Hutch is a Ruby library for enabling asynchronous inter-service communication using RabbitMQ'
18
18
  gem.version = Hutch::VERSION.dup
19
- gem.required_ruby_version = '>= 2.6'
19
+ gem.required_ruby_version = '>= 3.0'
20
20
  gem.authors = ['Harry Marr', 'Michael Klishin']
21
21
  gem.homepage = 'https://github.com/ruby-amqp/hutch'
22
22
  gem.require_paths = ['lib']
@@ -11,7 +11,7 @@ module Hutch
11
11
  ConnectionRefused = Bunny::TCPConnectionFailed
12
12
  PreconditionFailed = Bunny::PreconditionFailed
13
13
 
14
- def_delegators :@connection, :start, :disconnect, :close, :create_channel, :open?
14
+ def_delegators :@connection, :start, :disconnect, :close, :create_channel, :open?, :recover_channel_topology
15
15
 
16
16
  def initialize(opts={})
17
17
  @connection = Bunny.new(opts)
data/lib/hutch/broker.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  require 'active_support/core_ext/object/blank'
2
2
 
3
3
  require 'carrot-top'
4
- require 'ostruct'
5
4
  require 'hutch/logging'
6
5
  require 'hutch/exceptions'
7
6
  require 'hutch/publisher'
@@ -114,6 +113,8 @@ module Hutch
114
113
  logger.info 'enabling publisher confirms'
115
114
  ch.confirm_select
116
115
  end
116
+
117
+ install_channel_recovery!(ch)
117
118
  end
118
119
  end
119
120
 
@@ -121,6 +122,20 @@ module Hutch
121
122
  @channel = open_channel
122
123
  end
123
124
 
125
+ def install_channel_recovery!(ch)
126
+ ch.on_error do |channel, close|
127
+ next unless close.delivery_ack_timeout?
128
+
129
+ begin
130
+ channel.reopen
131
+ connection.recover_channel_topology(channel)
132
+ logger.warn 'recovered consumer channel after a delivery acknowledgement timeout'
133
+ rescue => ex
134
+ logger.error "channel recovery failed: #{ex.class}: #{ex.message}"
135
+ end
136
+ end
137
+ end
138
+
124
139
  def declare_exchange(ch = channel)
125
140
  exchange_name = @config[:mq_exchange]
126
141
  exchange_type = @config[:mq_exchange_type]
@@ -171,15 +186,19 @@ module Hutch
171
186
  @config[:tracer] && @config[:tracer] != Hutch::Tracers::NullTracer
172
187
  end
173
188
 
174
- # Create / get a durable queue and apply namespace if it exists.
189
+ # Create / get a durable queue.
175
190
  def queue(name, options = {})
176
191
  with_bunny_precondition_handler('queue') do
177
- namespace = @config[:namespace].to_s.downcase.gsub(/[^-_:\.\w]/, "")
178
- queue_name = namespace.present? ? "#{namespace}:#{name}" : name
179
- channel.queue(queue_name, **options)
192
+ channel.queue(name, **options)
180
193
  end
181
194
  end
182
195
 
196
+ # Apply the configured namespace prefix to a queue name.
197
+ def namespaced_queue_name(name)
198
+ namespace = @config[:namespace].to_s.downcase.gsub(/[^-_:\.\w]/, "")
199
+ namespace.present? ? "#{namespace}:#{name}" : name
200
+ end
201
+
183
202
  # Return a mapping of queue names to the routing keys they're bound to.
184
203
  def bindings
185
204
  results = Hash.new { |hash, key| hash[key] = [] }
@@ -269,8 +288,11 @@ module Hutch
269
288
 
270
289
  private
271
290
 
291
+ Config = Struct.new(:host, :port, :username, :password, :ssl, :protocol, :sanitized_uri)
292
+ private_constant :Config
293
+
272
294
  def api_config
273
- @api_config ||= OpenStruct.new.tap do |config|
295
+ @api_config ||= Config.new.tap do |config|
274
296
  config.host = @config[:mq_api_host]
275
297
  config.port = @config[:mq_api_port]
276
298
  config.username = @config[:mq_username]
@@ -47,6 +47,15 @@ module Hutch
47
47
  @queue_name = name
48
48
  end
49
49
 
50
+ # Opt out of the namespace prefix for this consumer's queue
51
+ def without_namespace
52
+ @without_namespace = true
53
+ end
54
+
55
+ def without_namespace?
56
+ !!@without_namespace
57
+ end
58
+
50
59
  # Explicitly set the queue mode to 'lazy'
51
60
  def lazy_queue
52
61
  @queue_mode = 'lazy'
@@ -10,6 +10,9 @@ module Hutch
10
10
  unless Raven.respond_to?(:capture_exception)
11
11
  raise "The Hutch Sentry error handler requires Raven >= 0.4.0"
12
12
  end
13
+
14
+ warn "[DEPRECATION] Hutch::ErrorHandlers::SentryRaven is deprecated and will be removed in Hutch 2.0. " \
15
+ "Use Hutch::ErrorHandlers::Sentry (backed by the sentry-ruby gem) instead." \
13
16
  end
14
17
 
15
18
  def handle(properties, payload, consumer, ex)
@@ -1,5 +1,12 @@
1
- require 'ddtrace'
2
- require 'ddtrace/auto_instrument'
1
+ begin
2
+ require 'datadog'
3
+ require 'datadog/auto_instrument'
4
+ rescue LoadError
5
+ require 'ddtrace'
6
+ require 'ddtrace/auto_instrument'
7
+ warn "[DEPRECATION] The ddtrace gem is deprecated and Hutch will require the datadog gem in 2.0. " \
8
+ "Please switch to the datadog gem."
9
+ end
3
10
 
4
11
  module Hutch
5
12
  module Tracers
data/lib/hutch/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Hutch
2
- VERSION = '1.3.1'.freeze
2
+ VERSION = '1.4.0'.freeze
3
3
  end
data/lib/hutch/worker.rb CHANGED
@@ -45,9 +45,11 @@ module Hutch
45
45
  # Bind a consumer's routing keys to its queue, and set up a subscription to
46
46
  # receive messages sent to the queue.
47
47
  def setup_queue(consumer)
48
- logger.info "setting up queue: #{consumer.get_queue_name}"
48
+ queue_name = consumer.get_queue_name
49
+ queue_name = @broker.namespaced_queue_name(queue_name) unless consumer.without_namespace?
50
+ logger.info "setting up queue: #{queue_name}"
49
51
 
50
- queue = @broker.queue(consumer.get_queue_name, consumer.get_options)
52
+ queue = @broker.queue(queue_name, consumer.get_options)
51
53
  @broker.bind_queue(queue, consumer.routing_keys)
52
54
 
53
55
  queue.subscribe(consumer_tag: unique_consumer_tag, manual_ack: true) do |*args|
@@ -95,7 +95,7 @@ describe Hutch::Broker do
95
95
 
96
96
  context 'when configured with a URI' do
97
97
  context 'which specifies the port' do
98
- before { config[:uri] = 'amqp://guest:guest@127.0.0.1:5672/' }
98
+ before { config[:uri] = "amqp://#{config[:mq_username]}:#{config[:mq_password]}@#{config[:mq_host]}:#{config[:mq_port]}/" }
99
99
 
100
100
  it 'successfully connects' do
101
101
  c = broker.open_connection
@@ -105,7 +105,7 @@ describe Hutch::Broker do
105
105
  end
106
106
 
107
107
  context 'which does not specify port and uses the amqp scheme' do
108
- before { config[:uri] = 'amqp://guest:guest@127.0.0.1/' }
108
+ before { config[:uri] = "amqp://#{config[:mq_username]}:#{config[:mq_password]}@#{config[:mq_host]}/" }
109
109
 
110
110
  it 'successfully connects' do
111
111
  c = broker.open_connection
@@ -115,7 +115,7 @@ describe Hutch::Broker do
115
115
  end
116
116
 
117
117
  context 'which specifies the amqps scheme' do
118
- before { config[:uri] = 'amqps://guest:guest@127.0.0.1/' }
118
+ before { config[:uri] = "amqps://#{config[:mq_username]}:#{config[:mq_password]}@#{config[:mq_host]}/" }
119
119
 
120
120
  it 'utilises TLS' do
121
121
  expect(Hutch::Adapter).to receive(:new).with(
@@ -256,16 +256,30 @@ describe Hutch::Broker do
256
256
  let(:arguments) { { foo: :bar } }
257
257
  before { allow(broker).to receive(:channel) { channel } }
258
258
 
259
- it 'applies a global namespace' do
260
- config[:namespace] = 'mirror-all.service'
261
- expect(broker.channel).to receive(:queue) do |*args|
262
- args.first == ''
263
- args.last == arguments
264
- end
259
+ it 'creates a queue with the given name' do
260
+ expect(broker.channel).to receive(:queue).with('test', arguments: arguments)
265
261
  broker.queue('test'.freeze, arguments: arguments)
266
262
  end
267
263
  end
268
264
 
265
+ describe '#namespaced_queue_name' do
266
+ context 'with a namespace configured' do
267
+ before { config[:namespace] = 'mirror-all.service' }
268
+
269
+ it 'prepends the namespace' do
270
+ expect(broker.namespaced_queue_name('test')).to eq('mirror-all.service:test')
271
+ end
272
+ end
273
+
274
+ context 'without a namespace configured' do
275
+ before { config[:namespace] = nil }
276
+
277
+ it 'returns the name unchanged' do
278
+ expect(broker.namespaced_queue_name('test')).to eq('test')
279
+ end
280
+ end
281
+ end
282
+
269
283
  describe '#bindings', rabbitmq: true do
270
284
  around { |example| broker.connect { example.run } }
271
285
  subject { broker.bindings }
@@ -97,6 +97,17 @@ describe Hutch::Consumer do
97
97
  end
98
98
  end
99
99
 
100
+ describe '.without_namespace' do
101
+ it 'is false by default' do
102
+ expect(simple_consumer.without_namespace?).to eq(false)
103
+ end
104
+
105
+ it 'returns true after calling without_namespace' do
106
+ simple_consumer.without_namespace
107
+ expect(simple_consumer.without_namespace?).to eq(true)
108
+ end
109
+ end
110
+
100
111
  describe 'default queue mode' do
101
112
  it 'does not specify any mode by default' do
102
113
  expect(simple_consumer.queue_mode).to eq(nil)
@@ -14,7 +14,7 @@ describe Hutch::ErrorHandlers::Airbrake do
14
14
 
15
15
  it "logs the error to Airbrake" do
16
16
  message_id = "1"
17
- properties = OpenStruct.new(message_id: message_id)
17
+ properties = Struct.new(:message_id).new(message_id)
18
18
  payload = "{}"
19
19
  consumer = double
20
20
  ex = error
@@ -23,7 +23,7 @@ describe Hutch::ErrorHandlers::Bugsnag do
23
23
 
24
24
  it "logs the error to Bugsnag" do
25
25
  message_id = "1"
26
- properties = OpenStruct.new(message_id: message_id)
26
+ properties = Struct.new(:message_id).new(message_id)
27
27
  payload = "{}"
28
28
  consumer = double
29
29
  ex = error
@@ -14,7 +14,7 @@ describe Hutch::ErrorHandlers::Honeybadger do
14
14
 
15
15
  it "logs the error to Honeybadger" do
16
16
  message_id = "1"
17
- properties = OpenStruct.new(message_id: message_id)
17
+ properties = Struct.new(:message_id).new(message_id)
18
18
  payload = "{}"
19
19
  consumer = double
20
20
  ex = error
@@ -4,7 +4,7 @@ describe Hutch::ErrorHandlers::Logger do
4
4
  let(:error_handler) { Hutch::ErrorHandlers::Logger.new }
5
5
 
6
6
  describe '#handle' do
7
- let(:properties) { OpenStruct.new(message_id: "1") }
7
+ let(:properties) { Struct.new(:message_id).new("1") }
8
8
  let(:payload) { "{}" }
9
9
  let(:error) { double(message: "Stuff went wrong", class: "RuntimeError",
10
10
  backtrace: ["line 1", "line 2"]) }
@@ -14,7 +14,7 @@ describe Hutch::ErrorHandlers::Rollbar do
14
14
 
15
15
  it "logs the error to Rollbar" do
16
16
  message_id = "1"
17
- properties = OpenStruct.new(message_id: message_id)
17
+ properties = Struct.new(:message_id).new(message_id)
18
18
  payload = "{}"
19
19
  consumer = double
20
20
  ex = error
@@ -4,7 +4,7 @@ describe Hutch::ErrorHandlers::SentryRaven do
4
4
  let(:error_handler) { Hutch::ErrorHandlers::SentryRaven.new }
5
5
 
6
6
  describe '#handle' do
7
- let(:properties) { OpenStruct.new(message_id: "1") }
7
+ let(:properties) { Struct.new(:message_id).new("1") }
8
8
  let(:payload) { "{}" }
9
9
  let(:error) do
10
10
  begin
@@ -12,7 +12,7 @@ describe Hutch::ErrorHandlers::Sentry do
12
12
  end
13
13
 
14
14
  describe '#handle' do
15
- let(:properties) { OpenStruct.new(message_id: "1") }
15
+ let(:properties) { Struct.new(:message_id).new("1") }
16
16
  let(:payload) { "{}" }
17
17
  let(:error) do
18
18
  begin
@@ -16,7 +16,7 @@ RSpec.describe Hutch::Tracers::Datadog do
16
16
  end
17
17
 
18
18
  def class
19
- OpenStruct.new(name: 'ClassName')
19
+ Struct.new(:name).new('ClassName')
20
20
  end
21
21
 
22
22
  def process(message)
@@ -4,7 +4,8 @@ require 'hutch/worker'
4
4
  describe Hutch::Worker do
5
5
  let(:consumer) { double('Consumer', routing_keys: %w( a b c ),
6
6
  get_queue_name: 'consumer', get_arguments: {},
7
- get_options: {}, get_serializer: nil) }
7
+ get_options: {}, get_serializer: nil,
8
+ without_namespace?: false) }
8
9
  let(:consumers) { [consumer, double('Consumer')] }
9
10
  let(:broker) { Hutch::Broker.new }
10
11
  let(:setup_procs) { Array.new(2) { Proc.new {} } }
@@ -32,13 +33,27 @@ describe Hutch::Worker do
32
33
 
33
34
  describe '#setup_queue' do
34
35
  let(:queue) { double('Queue', bind: nil, subscribe: nil) }
35
- before { allow(broker).to receive_messages(queue: queue, bind_queue: nil) }
36
+ before { allow(broker).to receive_messages(queue: queue, bind_queue: nil, namespaced_queue_name: 'consumer') }
36
37
 
37
- it 'creates a queue' do
38
- expect(broker).to receive(:queue).with(consumer.get_queue_name, consumer.get_options).and_return(queue)
38
+ it 'creates a queue with a namespaced name' do
39
+ expect(broker).to receive(:namespaced_queue_name).with('consumer').and_return('ns:consumer')
40
+ expect(broker).to receive(:queue).with('ns:consumer', consumer.get_options).and_return(queue)
39
41
  worker.setup_queue(consumer)
40
42
  end
41
43
 
44
+ context 'when the consumer uses without_namespace' do
45
+ let(:consumer) { double('Consumer', routing_keys: %w( a b c ),
46
+ get_queue_name: 'consumer', get_arguments: {},
47
+ get_options: {}, get_serializer: nil,
48
+ without_namespace?: true) }
49
+
50
+ it 'creates a queue without applying the namespace' do
51
+ expect(broker).not_to receive(:namespaced_queue_name)
52
+ expect(broker).to receive(:queue).with('consumer', consumer.get_options).and_return(queue)
53
+ worker.setup_queue(consumer)
54
+ end
55
+ end
56
+
42
57
  it 'binds the queue to each of the routing keys' do
43
58
  expect(broker).to receive(:bind_queue).with(queue, %w( a b c ))
44
59
  worker.setup_queue(consumer)
@@ -61,6 +76,7 @@ describe Hutch::Worker do
61
76
  context 'with a configured consumer tag prefix that is too long' do
62
77
  let(:maximum_size) { 255 - SecureRandom.uuid.size - 1 }
63
78
  before { Hutch::Config.set(:consumer_tag_prefix, 'a'.*(maximum_size + 1)) }
79
+ after { Hutch::Config.set(:consumer_tag_prefix, 'hutch') }
64
80
 
65
81
  it 'raises an error' do
66
82
  expect { worker.setup_queue(consumer) }.to raise_error(/Tag must be 255 bytes long at most/)
@@ -0,0 +1,139 @@
1
+ require 'spec_helper'
2
+ require 'hutch/broker'
3
+ require 'hutch/worker'
4
+ require 'hutch/consumer'
5
+ require 'bunny'
6
+ require 'json'
7
+ require 'securerandom'
8
+ require 'timeout'
9
+
10
+ describe 'channel recovery after delivery acknowledgement timeout', rabbitmq: true, adapter: :bunny do
11
+ let(:log) { StringIO.new }
12
+ let(:logger) { Logger.new(log) }
13
+ let(:exchange_name) { "hutch.integration.exchange.#{SecureRandom.hex(4)}" }
14
+ let(:queue_name) { "hutch.integration.queue.#{SecureRandom.hex(4)}" }
15
+ let(:routing_key) { "hutch.integration.key.#{SecureRandom.hex(4)}" }
16
+
17
+ let(:processed) { [] }
18
+ let(:processed_lock) { Mutex.new }
19
+ let(:timed_out_once) { [false] }
20
+
21
+ let(:consumer_class) do
22
+ msgs = processed
23
+ lock = processed_lock
24
+ rk = routing_key
25
+ qn = queue_name
26
+ timed_out = timed_out_once
27
+
28
+ Class.new do
29
+ include Hutch::Consumer
30
+
31
+ consume rk
32
+ queue_name qn
33
+ arguments(
34
+ 'x-queue-type' => 'quorum',
35
+ 'x-consumer-timeout' => 60_000
36
+ )
37
+
38
+ define_method(:process) do |message|
39
+ if message['id'] == 'trigger-timeout' && !timed_out[0]
40
+ timed_out[0] = true
41
+ sleep 210
42
+ end
43
+
44
+ lock.synchronize { msgs << message['id'] }
45
+ end
46
+ end
47
+ end
48
+
49
+ let(:broker) { Hutch::Broker.new }
50
+ let(:worker) { Hutch::Worker.new(broker, [consumer_class], []) }
51
+
52
+ let(:publisher) do
53
+ Bunny.new(
54
+ host: Hutch::Config[:mq_host],
55
+ port: Hutch::Config[:mq_port],
56
+ username: Hutch::Config[:mq_username],
57
+ password: Hutch::Config[:mq_password],
58
+ vhost: Hutch::Config[:mq_vhost]
59
+ ).tap(&:start)
60
+ end
61
+
62
+ let(:publisher_channel) { publisher.create_channel }
63
+ let(:exchange) { publisher_channel.topic(exchange_name, durable: true) }
64
+
65
+ before do
66
+ Hutch::Logging.logger = logger
67
+ Hutch::Config.set(:mq_exchange, exchange_name)
68
+ Hutch::Config.set(:force_publisher_confirms, false)
69
+ Hutch::Config.set(:client_logger, logger)
70
+ end
71
+
72
+ after do
73
+ publisher_channel.close rescue nil
74
+ publisher.close rescue nil
75
+ broker.disconnect rescue nil
76
+ Hutch::Logging.logger = Logger.new(File::NULL)
77
+ end
78
+
79
+ def wait_for(timeout, label)
80
+ Timeout.timeout(timeout) do
81
+ loop do
82
+ return true if yield
83
+ sleep 0.25
84
+ end
85
+ end
86
+ rescue Timeout::Error
87
+ raise <<~MSG
88
+ Timed out waiting for: #{label}
89
+
90
+ processed_messages=#{processed_messages.inspect}
91
+ channel_open=#{broker.channel.open? rescue 'unknown'}
92
+ channel_closed=#{broker.channel.closed? rescue 'unknown'}
93
+
94
+ log output:
95
+ #{log_output}
96
+ MSG
97
+ end
98
+
99
+ def processed_messages
100
+ processed_lock.synchronize { processed.dup }
101
+ end
102
+
103
+ def log_output
104
+ log.rewind
105
+ log.read
106
+ end
107
+
108
+ def publish_message(id)
109
+ exchange.publish(
110
+ JSON.dump('id' => id),
111
+ routing_key: routing_key,
112
+ content_type: 'application/json',
113
+ persistent: true
114
+ )
115
+ end
116
+
117
+ # This spec is intentionally slow because RabbitMQ enforces delivery
118
+ # acknowledgement timeouts on a periodic sweep, not immediately at the deadline.
119
+ it 're-subscribes and consumes later messages after RabbitMQ closes the channel for ack timeout' do
120
+ broker.connect
121
+ worker.setup_queues
122
+
123
+ publish_message('trigger-timeout')
124
+
125
+ wait_for(240, 'delivery acknowledgement timeout') do
126
+ log_output.match?(/delivery acknowledgement on channel \d+ timed out/i)
127
+ end
128
+
129
+ publish_message('after-recovery')
130
+
131
+ wait_for(90, 'after-recovery message consumption') do
132
+ processed_messages.include?('after-recovery')
133
+ end
134
+
135
+ expect(log_output).to match(/delivery acknowledgement on channel \d+ timed out/i)
136
+ expect(log_output).to match(/recovered consumer channel after a delivery acknowledgement timeout/i)
137
+ expect(processed_messages).to include('after-recovery')
138
+ end
139
+ end
@@ -0,0 +1,48 @@
1
+ require 'spec_helper'
2
+ require 'hutch/broker'
3
+ require 'hutch/worker'
4
+ require 'hutch/consumer'
5
+ require 'securerandom'
6
+ require 'timeout'
7
+
8
+ describe 'publishing and consuming messages', rabbitmq: true, adapter: :bunny do
9
+ let(:exchange_name) { "hutch.test.#{SecureRandom.hex(4)}" }
10
+ let(:routing_key) { "test.message" }
11
+ let(:received) { [] }
12
+
13
+ let(:consumer_class) do
14
+ msgs = received
15
+ rk = routing_key
16
+ qn = "test_consumer_#{SecureRandom.hex(4)}"
17
+
18
+ Class.new do
19
+ include Hutch::Consumer
20
+ consume rk
21
+ queue_name qn
22
+
23
+ define_method(:process) { |message| msgs << message.body }
24
+ end
25
+ end
26
+
27
+ let(:broker) { Hutch::Broker.new }
28
+ let(:worker) { Hutch::Worker.new(broker, [consumer_class], []) }
29
+
30
+ before do
31
+ Hutch::Config.set(:mq_exchange, exchange_name)
32
+ end
33
+
34
+ after do
35
+ broker.disconnect rescue nil
36
+ end
37
+
38
+ it 'publishes and consumes a message' do
39
+ broker.connect
40
+ worker.setup_queues
41
+
42
+ broker.publish(routing_key, { test: 'data' })
43
+
44
+ Timeout.timeout(5) { sleep 0.1 until received.any? }
45
+
46
+ expect(received.first).to eq('test' => 'data')
47
+ end
48
+ end
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hutch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harry Marr
8
8
  - Michael Klishin
9
- autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2024-12-11 00:00:00.000000000 Z
11
+ date: 2026-04-08 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: bunny
@@ -17,20 +16,20 @@ dependencies:
17
16
  requirements:
18
17
  - - ">="
19
18
  - !ruby/object:Gem::Version
20
- version: '2.23'
19
+ version: '3.1'
21
20
  - - "<"
22
21
  - !ruby/object:Gem::Version
23
- version: '3.0'
22
+ version: '4.0'
24
23
  type: :runtime
25
24
  prerelease: false
26
25
  version_requirements: !ruby/object:Gem::Requirement
27
26
  requirements:
28
27
  - - ">="
29
28
  - !ruby/object:Gem::Version
30
- version: '2.23'
29
+ version: '3.1'
31
30
  - - "<"
32
31
  - !ruby/object:Gem::Version
33
- version: '3.0'
32
+ version: '4.0'
34
33
  - !ruby/object:Gem::Dependency
35
34
  name: carrot-top
36
35
  requirement: !ruby/object:Gem::Requirement
@@ -75,7 +74,6 @@ dependencies:
75
74
  version: '4.2'
76
75
  description: Hutch is a Ruby library for enabling asynchronous inter-service communication
77
76
  using RabbitMQ
78
- email:
79
77
  executables:
80
78
  - hutch
81
79
  extensions: []
@@ -85,6 +83,7 @@ files:
85
83
  - ".gitignore"
86
84
  - ".rspec"
87
85
  - ".yardopts"
86
+ - AGENTS.md
88
87
  - CHANGELOG.md
89
88
  - Gemfile
90
89
  - Guardfile
@@ -150,6 +149,8 @@ files:
150
149
  - spec/hutch/waiter_spec.rb
151
150
  - spec/hutch/worker_spec.rb
152
151
  - spec/hutch_spec.rb
152
+ - spec/integration/channel_recovery_spec.rb
153
+ - spec/integration/publish_consume_spec.rb
153
154
  - spec/spec_helper.rb
154
155
  - templates/default/class/html/settings.erb
155
156
  - templates/default/class/setup.rb
@@ -164,7 +165,6 @@ homepage: https://github.com/ruby-amqp/hutch
164
165
  licenses:
165
166
  - MIT
166
167
  metadata: {}
167
- post_install_message:
168
168
  rdoc_options: []
169
169
  require_paths:
170
170
  - lib
@@ -172,15 +172,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
172
172
  requirements:
173
173
  - - ">="
174
174
  - !ruby/object:Gem::Version
175
- version: '2.6'
175
+ version: '3.0'
176
176
  required_rubygems_version: !ruby/object:Gem::Requirement
177
177
  requirements:
178
178
  - - ">="
179
179
  - !ruby/object:Gem::Version
180
180
  version: '0'
181
181
  requirements: []
182
- rubygems_version: 3.5.22
183
- signing_key:
182
+ rubygems_version: 3.6.2
184
183
  specification_version: 4
185
184
  summary: Opinionated asynchronous inter-service communication using RabbitMQ
186
185
  test_files:
@@ -202,4 +201,6 @@ test_files:
202
201
  - spec/hutch/waiter_spec.rb
203
202
  - spec/hutch/worker_spec.rb
204
203
  - spec/hutch_spec.rb
204
+ - spec/integration/channel_recovery_spec.rb
205
+ - spec/integration/publish_consume_spec.rb
205
206
  - spec/spec_helper.rb