Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 68 additions & 15 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,29 @@ jobs:
docker_image:
type: string
description: "The Ruby or JRuby Docker image to test against"
gemfile:
type: string
description: "The Appraisal gemfile (Rails version) to test against"

docker:
# 1. The Primary Container (where your code actually runs)
- image: << parameters.docker_image >>
environment:
JRUBY_OPTS: "-J-Xmx1024m"
RAILS_ENV: test
# Tell your app where to find RabbitMQ (if your app uses this ENV var)
# Select the Rails version under test via the Appraisal gemfile.
BUNDLE_GEMFILE: << parameters.gemfile >>
# Tell the suite where to find RabbitMQ.
RABBITMQ_URL: "amqp://guest:guest@localhost:5672"
RABBITMQ_HOST: "localhost"
RABBITMQ_PORT: "5672"

# 2. The Service Container (runs in the background)
# 2. The Service Container (runs in the background).
# NOTE: action_subscriber declares transient (non-durable) queues by default,
# which RabbitMQ 4.x denies out of the box. rabbitmq:3.12 still permits them.
# If/when moving to a 4.x image, permit the deprecated feature via config:
# deprecated_features.permit.transient_nonexcl_queues = true
- image: rabbitmq:3.12-management
# If you need the management UI for debugging, use `rabbitmq:3-management` instead
# environment:
# RABBITMQ_DEFAULT_USER: guest
# RABBITMQ_DEFAULT_PASS: guest

working_directory: ~/project

Expand All @@ -35,24 +42,49 @@ jobs:
sudo apt-get update && sudo apt-get install -y build-essential git
fi
- checkout
# Note: We added the docker_image parameter to the cache key
# so MRI and JRuby gems don't conflict.
# Cache key includes the Ruby image + the specific appraisal gemfile + the gemspec
# + the Appraisals file, so MRI/JRuby and each Rails version get independent caches
# and a dependency change in either file busts them. (All lockfiles are gitignored,
# so we key on committed sources instead.)
- restore_cache:
keys:
- v1-gems-<< parameters.docker_image >>-{{ checksum "Gemfile.lock" }}
- v1-gems-<< parameters.docker_image >>-
- v4-gems-<< parameters.docker_image >>-<< parameters.gemfile >>-{{ checksum "action_subscriber.gemspec" }}-{{ checksum "Appraisals" }}
- v4-gems-<< parameters.docker_image >>-<< parameters.gemfile >>-

# gemfiles/ is gitignored and generated here, so it does not exist at checkout.
#
# Two things this step has to get right:
# 1. BUNDLE_GEMFILE is set job-wide to the target appraisal gemfile, which does
# not exist yet. Override it to the root Gemfile or appraisal tries to read
# the very file it is about to write.
# 2. appraisal runs under bundler, so the root Gemfile must be *installed*
# first -- otherwise it aborts with "Could not find gem ... in locally
# installed gems". The two bundles differ only in their Rails pins and share
# vendor/bundle, so the second install below is mostly a no-op.
- run:
name: Generate Appraisal Gemfiles
command: |
gem install bundler appraisal
bundle config set --local path 'vendor/bundle'
BUNDLE_GEMFILE=Gemfile bundle install --jobs=4 --retry=3
BUNDLE_GEMFILE=Gemfile bundle exec appraisal generate
ls -1 gemfiles/

- run:
name: Install Ruby Dependencies
command: |
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install --jobs=4 --retry=3

# Two paths: `bundle config --local` is relative to the directory holding
# BUNDLE_GEMFILE, so the root bundle lands in ./vendor/bundle while the
# appraisal bundle lands in ./gemfiles/vendor/bundle. Caching only the first
# would silently reinstall the gems the tests actually run against.
- save_cache:
paths:
- ./vendor/bundle
key: v1-gems-<< parameters.docker_image >>-{{ checksum "Gemfile.lock" }}
- ./gemfiles/vendor/bundle
key: v4-gems-<< parameters.docker_image >>-<< parameters.gemfile >>-{{ checksum "action_subscriber.gemspec" }}-{{ checksum "Appraisals" }}

# Wait for RabbitMQ to be ready before running tests.
# Service containers can sometimes take a few seconds to boot up.
Expand All @@ -77,14 +109,35 @@ jobs:

workflows:
version: 2
ruby_compatibility_matrix:
ruby_rails_compatibility_matrix:
jobs:
# Rails 6.1 - 7.2 run on every supported Ruby. Rails 6.1/7.0/7.1 need the
# default-gem shims on Ruby >= 3.4 (see Appraisals); 7.2 needs Ruby >= 3.1.
- build_and_test:
name: test-<< matrix.docker_image >>
name: test-<< matrix.docker_image >>-<< matrix.gemfile >>
matrix:
parameters:
docker_image:
- "cimg/ruby:3.1"
- "cimg/ruby:3.4"
- "jruby:9.4"
- "jruby:10.0"
- "jruby:10.0"
gemfile:
- "gemfiles/rails_6.1.gemfile"
- "gemfiles/rails_7.0.gemfile"
- "gemfiles/rails_7.1.gemfile"
- "gemfiles/rails_7.2.gemfile"

# Rails 8.0/8.1 require Ruby >= 3.2, so they get their own matrix rather
# than excludes against the Ruby 3.1-class images. jruby:9.4 targets Ruby
# 3.1 compatibility and is therefore not eligible here either.
- build_and_test:
name: test-<< matrix.docker_image >>-<< matrix.gemfile >>
matrix:
parameters:
docker_image:
- "cimg/ruby:3.4"
- "jruby:10.0"
gemfile:
- "gemfiles/rails_8.0.gemfile"
- "gemfiles/rails_8.1.gemfile"
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ tmp

Gemfile.lock

# Appraisal output. Both the .gemfile stubs and their locks are generated from
# the Appraisals file -- CI regenerates them, and locally you run:
# bundle exec appraisal generate
gemfiles/

# YARD artifacts
.yardoc
_yardoc
Expand Down
51 changes: 51 additions & 0 deletions Appraisals
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Appraisal matrix for the Rails/ActiveSupport versions action_subscriber supports.
#
# Notes on Ruby compatibility when running this matrix in CI:
# * rails 6.1 / 7.0 / 7.1 run on Ruby >= 2.7 (and on Ruby 3.4 with the
# default-gem shims added below, since logger/mutex_m/bigdecimal/drb/base64
# were removed from the default gem set).
# * rails 7.2 requires Ruby >= 3.1.
# * rails 8.0 / 8.1 require Ruby >= 3.2.
# Pair each gemfile with a compatible Ruby in the CI matrix.

# Shims required by ActiveSupport < 7.1 on Ruby >= 3.4 (default gems removed).
older_rails_shims = proc do
gem "logger"
gem "mutex_m"
gem "bigdecimal"
gem "drb"
gem "base64"
gem "benchmark"
end

appraise "rails-6.1" do
instance_exec(&older_rails_shims)
gem "activesupport", "~> 6.1.0"
gem "activerecord", "~> 6.1.0"
end

appraise "rails-7.0" do
instance_exec(&older_rails_shims)
gem "activesupport", "~> 7.0.0"
gem "activerecord", "~> 7.0.0"
end

appraise "rails-7.1" do
gem "activesupport", "~> 7.1.0"
gem "activerecord", "~> 7.1.0"
end

appraise "rails-7.2" do
gem "activesupport", "~> 7.2.0"
gem "activerecord", "~> 7.2.0"
end

appraise "rails-8.0" do
gem "activesupport", "~> 8.0.0"
gem "activerecord", "~> 8.0.0"
end

appraise "rails-8.1" do
gem "activesupport", "~> 8.1.0"
gem "activerecord", "~> 8.1.0"
end
103 changes: 103 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,86 @@ end

That will give you a similar behavior to the old `--mode=pop` where messages polled from the server, but with reduced latency.

Queue Types
-----------

Set the queue type globally, or per route:

```ruby
::ActionSubscriber.configure do |config|
config.queue_type = :quorum
end

::ActionSubscriber.draw_routes do
route UserSubscriber, :created, :queue_type => :quorum
route AuditSubscriber, :created, :queue_type => :broker_default
end
```

| Value | `x-queue-type` sent |
| --- | --- |
| `nil` (default), or `:broker_default` | *not sent* — the broker applies its own `default_queue_type` |
| `:classic` | `classic` |
| `:quorum` | `quorum` |
| `:stream` | `stream` |

The default declares a queue without expressing an opinion, which lets an
operator move a vhost onto quorum queues with a broker policy instead of a code
change. `:broker_default` is accepted as a more readable spelling of `nil`; both
normalize to `nil`, and `config.queue_type` always reads back as `nil` or one of
the three type symbols.

`:quorum` and `:stream` queues only exist as durable queues, so those two values
force `:durable => true` on the route regardless of what you pass.

Invalid values raise an `ArgumentError` at the point they are assigned, rather
than later when routes are drawn or a queue is declared.

> Note: a queue's type is fixed at declaration. Changing this setting will not
> convert an existing queue — the queue has to be deleted and redeclared, and
> redeclaring an existing queue with a conflicting type fails with
> `PRECONDITION_FAILED`.

### Breaking change on JRuby

Prior to this setting the two drivers disagreed. `march_hare` defaults its
`:type` option to `classic` and so injected `x-queue-type: classic` on every
queue it declared, while `bunny` sent no argument at all. ActionSubscriber now
passes `:type` explicitly on both drivers and defaults to `nil`, so neither
platform sends `x-queue-type`.

**MRI behavior is unchanged. On JRuby, newly declared queues change from
`classic` to whatever the broker defaults to.** Set `config.queue_type = :classic`
to keep the previous JRuby behavior.

The reason this matters beyond new queues: because queue type is fixed at
declaration, an existing `classic` queue is now *redeclared* without
`x-queue-type`. That is harmless on a vhost whose `default_queue_type` is
classic, since the broker resolves to the same type. It fails with
`PRECONDITION_FAILED` on a vhost whose default is `quorum` or `stream`. Before
upgrading a JRuby deployment, audit the `default_queue_type` of every vhost it
connects to:

```
rabbitmqctl list_vhosts name default_queue_type
```

If any are non-classic, set `config.queue_type = :classic` before rolling out.

### Known limitation: retry queues

`ActionSubscriber::MessageRetry` declares its `*.retry_*` queues using the
**global** `config.queue_type`, not the type of the route that produced the
message. A route that opts into `:quorum` while the global setting is left at
the default will dead-letter into a retry queue of a different type.

If you rely on per-route queue types and on retries, set `config.queue_type` to
match rather than setting it per route.

Note also that retry queues carry `x-message-ttl` and `x-dead-letter-exchange`,
which streams do not support — so a global `config.queue_type = :stream` will
make every retry declaration fail.

Supported Message Types
-----------------
ActionSubscriber support JSON and plain text out of the box, but you can easily
Expand Down Expand Up @@ -135,6 +215,7 @@ Other configuration options include :
* config.network_recovery_interval - reconnection interval for TCP connection failures (default 1)
* config.password - RabbitMQ password (default "guest")
* config.prefetch - number of messages to hold in the local queue in subscriber mode
* config.queue_type - default queue type for all routes: `nil` (default, defers to the broker), `:classic`, `:quorum` or `:stream`
* config.resubscribe_on_consumer_cancellation - resubscribe when the consumer is cancelled (queue deleted or cluster fails, default true)
* config.seconds_to_wait_for_graceful_shutdown - time to wait before force stopping server after shutdown signal
* config.threadpool_size - set the number of threads available to action_subscriber
Expand Down Expand Up @@ -241,3 +322,25 @@ $ cd action_subscriber
$ bundle install
$ bundle exec rspec
```

### Testing against multiple Rails versions

The supported Rails versions are declared in `Appraisals`. The `gemfiles/`
directory is **generated, not committed** — it is gitignored, and CI regenerates
it on every run. To create it locally:

```
$ bundle exec appraisal generate # writes gemfiles/*.gemfile
$ bundle exec appraisal install # resolves a lockfile for each
```

Then run the suite against one version, or all of them:

```
$ BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle exec rspec
$ bundle exec appraisal rspec
```

Re-run `appraisal generate` after editing `Appraisals`. Note that Rails 7.2
requires Ruby >= 3.1 and Rails 8.0/8.1 require Ruby >= 3.2, so those gemfiles
will not resolve on older interpreters.
9 changes: 9 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,14 @@ require "rspec/core/rake_task"
desc "Run specs"
RSpec::Core::RakeTask.new(:spec)

# Appraisal wires up per-Rails-version tasks (rake appraisal:rails-8.1 spec, etc.)
# when the appraisal gem is available. It is only a development dependency, so we
# guard the require to keep the Rakefile usable without it (e.g. from an installed gem).
begin
require "appraisal"
rescue LoadError
# appraisal not installed; per-version tasks are unavailable
end

desc "Run specs (default)"
task :default => :spec
3 changes: 2 additions & 1 deletion action_subscriber.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ Gem::Specification.new do |spec|
spec.add_dependency "middleware"
spec.add_dependency "thor"

spec.add_development_dependency "active_publisher", "1.6.0.pre1"
spec.add_development_dependency "active_publisher", "1.6.0"
spec.add_development_dependency "activerecord", ">= 6.0"
spec.add_development_dependency "appraisal", "~> 2.5"
spec.add_development_dependency "bundler"
spec.add_development_dependency "pry-nav"
spec.add_development_dependency "rabbitmq_http_api_client", "~> 1.15.0"
Expand Down
20 changes: 20 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
### Changelog

### 6.0.0 - August 6, 2026

**Breaking change on JRuby.** `march_hare` defaults its `:type` option to
`classic` and so was injecting `x-queue-type: classic` on every queue it
declared, while `bunny` sent no argument at all. Both drivers are now passed
`:type` explicitly, defaulting to `nil`, so neither sends `x-queue-type` and the
broker's own `default_queue_type` applies.

MRI behavior is unchanged. On JRuby, newly declared queues change from `classic`
to whatever the broker defaults to. Set `config.queue_type = :classic` to retain
the previous JRuby behavior. Note that queue type is fixed at declaration:
redeclaring an existing queue with a conflicting type fails with
`PRECONDITION_FAILED`, so audit any vhost whose `default_queue_type` is not
classic before upgrading.

Added a first party `queue_type` setting behind that change, configurable
globally (`config.queue_type`) or per route (`:queue_type => ...`). Values are
`nil` (default), `:classic`, `:quorum` and `:stream`; `:broker_default` is
accepted as a readable alias for `nil`. `:quorum` and `:stream` force the route
to be durable. Invalid values raise where they are assigned.

### 5.4.0 - April 10, 2026
Added Ruby 3.4 / JRuby 10 support.
2 changes: 1 addition & 1 deletion lib/action_subscriber/bunny/subscriber.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def start_subscriber_for_subscription(subscription)
def setup_queue(route)
channel = ::ActionSubscriber::RabbitConnection.with_connection{|connection| connection.create_channel(nil, 1) }
exchange = channel.topic(route.exchange)
queue = channel.queue(route.queue, :durable => route.durable)
queue = channel.queue(route.queue, :durable => route.durable, :type => route.driver_queue_type)
queue.bind(exchange, :routing_key => route.routing_key)
queue
end
Expand Down
Loading