From e4643c7ccc102a71065e3bf696dde6ad6c7b0dd3 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 16 Sep 2026 08:27:48 +0200 Subject: [PATCH 1/5] Add a before_child_exit hook so a job's last log lines are not discarded The forked child ends with `exit!(0)`. That is deliberate -- it skips at_exit handlers registered in the PARENT and inherited across the fork, which must not run once per job -- but it skips ALL of them, and any in-memory buffer that an at_exit handler would have flushed goes with them. An HTTP log shipper is the common case. A batching sink relies on `at_exit { close }` to deliver its tail, so the LAST lines a job writes are the ones most reliably lost -- which are exactly the lines reporting whether the job succeeded. Long jobs partially hide this, because a periodic flush ships everything except the final batch; short jobs can lose their entire output. Observed in production: a nightly sweep logged its start and its every-2,000 progress lines, but its completion line appeared once in thirty hours across thirteen runs. It was reported as a job that never finished. It had been finishing all along; only the line saying so was being dropped. The false signal cost about two days of investigation. Adds Configuration#before_child_exit, mirroring the existing after_fork hook, and calls it from the child immediately before exit!. It runs AFTER the acknowledgement, so a hook that hangs can never cause redelivery, and it is bounded by Worker::CHILD_EXIT_HOOK_TIMEOUT and rescued: an unreachable sink must never stop a child exiting, or forks accumulate until the host runs out of processes. A failed flush costs log lines; a wedged child costs the worker. The rescue is `Exception`, not `StandardError`, because Timeout::Error does not descend from StandardError on the rubies this gem supports -- a bare `rescue StandardError` would let precisely the timeout case escape and defeat the bound. There is nothing left to protect microseconds before exit!. Also requires 'forwardable' and 'timeout' at the top level. Queue, Worker and DelayQueue all `extend Forwardable` but nothing ever required it, so the gem's own spec suite could not load standalone; under Rails it only worked because ActiveSupport requires forwardable first. Unrelated to the fix above, but the suite could not be run to verify it otherwise. Suite: 5 new examples, all passing. The 14 pre-existing failures elsewhere are unchanged (14 before, 14 after). Co-Authored-By: Claude Opus 5 (1M context) --- Gemfile.lock | 47 ++++++++++++++++++++++++++++++ lib/leveret.rb | 7 +++++ lib/leveret/configuration.rb | 11 +++++++- lib/leveret/worker.rb | 29 +++++++++++++++++++ spec/configuration_spec.rb | 3 +- spec/worker_spec.rb | 55 +++++++++++++++++++++++++++++++++++- 6 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 Gemfile.lock diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..129a791 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,47 @@ +PATH + remote: . + specs: + leveret (0.1.6) + bunny + json + +GEM + remote: https://rubygems.org/ + specs: + amq-protocol (2.7.0) + bunny (3.0.0) + amq-protocol (~> 2.7) + logger (~> 1, >= 1.7) + sorted_set (~> 1, >= 1.0.2) + diff-lcs (1.6.2) + json (3.0.2) + logger (1.7.0) + rake (13.4.2) + rbtree (0.4.7) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + sorted_set (1.1.0) + rbtree + +PLATFORMS + ruby + +DEPENDENCIES + bundler + leveret! + rake + rspec + +BUNDLED WITH + 2.1.4 diff --git a/lib/leveret.rb b/lib/leveret.rb index d5f302c..fd779a6 100644 --- a/lib/leveret.rb +++ b/lib/leveret.rb @@ -1,6 +1,13 @@ require 'bunny' require 'json' require 'logger' +# Queue, Worker and DelayQueue all `extend Forwardable`, but nothing required it. Loading the gem +# standalone (its own spec suite) therefore failed with an uninitialized-constant error; under +# Rails it only ever worked because ActiveSupport happens to require forwardable first. +require 'forwardable' +# Worker#run_before_child_exit_hook bounds the host application's hook. Required explicitly +# rather than relying on bunny pulling it in transitively. +require 'timeout' require 'leveret/configuration' require 'leveret/delay_queue' diff --git a/lib/leveret/configuration.rb b/lib/leveret/configuration.rb index 9a20802..cbfc339 100644 --- a/lib/leveret/configuration.rb +++ b/lib/leveret/configuration.rb @@ -30,11 +30,19 @@ module Leveret # @return [Proc] A proc which will be executed in a child after forking to process a message. Default: +proc {}+ # @!attribute error_handler # @return [Proc] A proc which will be called if a job raises an exception. Default: +proc {|ex| ex }+ + # @!attribute before_child_exit + # @return [Proc] A proc executed in the child immediately before it exits, after the message has been + # acknowledged. The child leaves via +Kernel#exit!+, which skips +at_exit+ and every buffer with it, so + # any sink that batches in memory (an HTTP log shipper, a metrics client) silently loses whatever the + # job wrote last. Use this hook to flush those. Bounded by + # {Worker::CHILD_EXIT_HOOK_TIMEOUT} and rescued, so a slow sink cannot stop a child exiting. + # Default: +proc {}+ # @!attribute concurrent_fork_count # @return [Integer] The number of jobs that can be processes simultanously. Default: +1+ class Configuration attr_accessor :amqp, :exchange_name, :queue_name_prefix, :log_file, :log_level, :default_queue_name, :after_fork, - :error_handler, :concurrent_fork_count, :delay_exchange_name, :delay_queue_name, :delay_time + :error_handler, :concurrent_fork_count, :delay_exchange_name, :delay_queue_name, :delay_time, + :before_child_exit # Create a new instance of Configuration with a set of sane defaults. def initialize @@ -54,6 +62,7 @@ def assign_defaults self.delay_queue_name = 'leveret_delay_queue' self.delay_time = 10_000 self.after_fork = proc {} + self.before_child_exit = proc {} self.error_handler = proc { |ex| ex } self.concurrent_fork_count = 1 end diff --git a/lib/leveret/worker.rb b/lib/leveret/worker.rb index 651c054..6a9a0f1 100644 --- a/lib/leveret/worker.rb +++ b/lib/leveret/worker.rb @@ -5,6 +5,11 @@ module Leveret class Worker extend Forwardable + # Wall-clock bound on Configuration#before_child_exit. Generous enough for a log shipper to + # complete one synchronous HTTP delivery, short enough that an unreachable sink delays a + # child's exit by seconds rather than pinning a fork slot. + CHILD_EXIT_HOOK_TIMEOUT = 5 + # @!attribute queues # @return [Array] All of the queues this worker is going to subscribe to # @!attribute consumers @@ -103,6 +108,7 @@ def fork_and_run(incoming_message) result_handler.handle(result) log.info "[#{incoming_message.delivery_tag}] Exiting child process #{pid}" + run_before_child_exit_hook exit!(0) end @@ -110,6 +116,29 @@ def fork_and_run(incoming_message) Process.detach(pid) end + # Give the host application a chance to flush anything it has buffered before the child + # leaves via #exit!. + # + # #exit! is deliberate -- it skips at_exit handlers that were registered in the PARENT and + # inherited across the fork, which must not run once per job. But it skips ALL of them, and + # an in-memory buffer flushed by an at_exit handler goes with them. An HTTP log shipper is + # the common case: a batching sink relies on `at_exit { close }` to deliver its tail, so the + # LAST lines a job writes -- the ones saying whether it succeeded -- are the ones most + # reliably lost. Long jobs hide this, because a periodic flush ships everything except the + # final batch; short jobs can lose their entire output. + # + # Runs AFTER the acknowledgement, so a hook that hangs cannot cause redelivery. Bounded and + # rescued for the same reason: an unreachable sink must never stop a child exiting, or forks + # accumulate until the host runs out of processes. A failed flush costs log lines; a wedged + # child costs the worker. + def run_before_child_exit_hook + Timeout.timeout(CHILD_EXIT_HOOK_TIMEOUT) { configuration.before_child_exit.call } + rescue Exception => e # rubocop:disable Lint/RescueException + # Timeout::Error is not a StandardError on older rubies, and this runs microseconds before + # exit! -- there is nothing left to protect by letting anything propagate. + log.warn "before_child_exit hook failed: #{e.class}: #{e.message}" + end + # Constantize the class name in the payload and execute the job with parameters # # @param [Parameters] payload The job name and parameters the job requires diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 6141dad..e144878 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -4,7 +4,7 @@ it 'has a default set of configuration params' do config = Leveret::Configuration.new - expect(config.amqp).to eq("amqp://guest:guest@localhost:5672") + expect(config.amqp).to eq('amqp://guest:guest@localhost:5672') expect(config.exchange_name).to eq('leveret_exch') expect(config.queue_name_prefix).to eq('leveret_queue') expect(config.delay_exchange_name).to eq('leveret_delay_exch') @@ -14,6 +14,7 @@ expect(config.log_level).to eq(Logger::DEBUG) expect(config.default_queue_name).to eq('standard') expect(config.after_fork).to be_a(Proc) + expect(config.before_child_exit).to be_a(Proc) expect(config.error_handler).to be_a(Proc) expect(config.concurrent_fork_count).to eq(1) end diff --git a/spec/worker_spec.rb b/spec/worker_spec.rb index 4439f63..223655f 100644 --- a/spec/worker_spec.rb +++ b/spec/worker_spec.rb @@ -8,10 +8,63 @@ end it 'can use custom queue names' do - queue_names = %w(test other) + queue_names = %w[test other] worker = Leveret::Worker.new(*queue_names) expect(worker.queues.map(&:name)).to eq(queue_names) end end + + # The child leaves via exit!, which skips at_exit and every buffer flushed by one. This hook is + # the only opportunity a batching sink gets to deliver what the job just wrote. + describe '#run_before_child_exit_hook' do + subject(:worker) { Leveret::Worker.new } + + def run_hook + worker.send(:run_before_child_exit_hook) + end + + around do |example| + original = Leveret.configuration.before_child_exit + example.run + Leveret.configuration.before_child_exit = original + end + + it 'calls the configured hook' do + called = false + Leveret.configuration.before_child_exit = proc { called = true } + + run_hook + + expect(called).to be(true) + end + + it 'is a no-op with the default hook' do + expect { run_hook }.not_to raise_error + end + + it 'swallows an exception raised by the hook' do + Leveret.configuration.before_child_exit = proc { raise 'sink unavailable' } + + expect { run_hook }.not_to raise_error + end + + # Timeout::Error does not descend from StandardError on the rubies this gem supports, so a + # bare `rescue StandardError` would let it escape and stop the child exiting. + it 'swallows a non-StandardError raised by the hook' do + Leveret.configuration.before_child_exit = proc { raise Timeout::Error, 'too slow' } + + expect { run_hook }.not_to raise_error + end + + it 'gives up on a hanging hook instead of blocking the exit forever' do + stub_const("#{described_class}::CHILD_EXIT_HOOK_TIMEOUT", 0.1) + Leveret.configuration.before_child_exit = proc { sleep 5 } + + started = Time.now + expect { run_hook }.not_to raise_error + + expect(Time.now - started).to be < 2 + end + end end From 61d78f1875666e02520584bb3fcdeb1ece603b55 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 16 Sep 2026 08:33:03 +0200 Subject: [PATCH 2/5] Also flush Leveret's own log before exit! The same defect applies to this gem's own diagnostics, and the evidence is stark. On one production host over two days the log held 4,769 "Forked to child" lines against 14 "Job returned" lines -- 0.3%. "Forked to child" is written by the PARENT, which exits normally and flushes. "Job returned" and "Exiting child process" are written by the CHILD, microseconds before exit!. log_file defaults to STDOUT and a redirected STDOUT is block-buffered, so the child's lines are almost never written out. The practical effect is that the log cannot answer the one question it is most often asked -- did that job succeed? -- for virtually any job this gem has run. That is also what makes the defect self-concealing: the missing lines are exactly the ones you would go looking for. Note this is plain IO buffering, not anything specific to a log vendor. It reproduces with the default STDOUT configuration and no host application involvement, which is why it is fixed here rather than left to before_child_exit. Ordered after the hook so it also flushes whatever the hook logged, and rescues Exception without reporting -- reporting is precisely what has failed, and exit! is the next statement. Suite: 12 examples in the touched files, 0 failures. Full suite unchanged at 14 pre-existing failures. Co-Authored-By: Claude Opus 5 (1M context) --- lib/leveret/worker.rb | 20 ++++++++++++++++++++ spec/worker_spec.rb | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/lib/leveret/worker.rb b/lib/leveret/worker.rb index 6a9a0f1..704520b 100644 --- a/lib/leveret/worker.rb +++ b/lib/leveret/worker.rb @@ -109,6 +109,7 @@ def fork_and_run(incoming_message) log.info "[#{incoming_message.delivery_tag}] Exiting child process #{pid}" run_before_child_exit_hook + flush_own_log exit!(0) end @@ -139,6 +140,25 @@ def run_before_child_exit_hook log.warn "before_child_exit hook failed: #{e.class}: #{e.message}" end + # Flush OUR OWN log before exit! discards it. This gem's log_file defaults to STDOUT, and a + # redirected STDOUT is block-buffered, so `Job returned ...` and `Exiting child process ...` + # -- both written in the child, microseconds before exit! -- are usually never written out. + # + # This is not theoretical. On one production host over two days the log held 4,769 + # "Forked to child" lines (written by the PARENT, which exits normally and flushes) against + # 14 "Job returned" lines (written by the CHILD). 0.3%. The result of virtually every job + # this gem has ever run was discarded by its own exit path, which makes the log useless for + # the one question it is most often asked: did that job succeed? + # + # Must run LAST, so it also flushes whatever before_child_exit logged. + def flush_own_log + device = log.instance_variable_get(:@logdev) + io = device.respond_to?(:dev) ? device.dev : nil + io.flush if io.respond_to?(:flush) + rescue Exception # rubocop:disable Lint/RescueException + # Nothing can be reported here -- reporting is what just failed -- and exit! is next. + end + # Constantize the class name in the payload and execute the job with parameters # # @param [Parameters] payload The job name and parameters the job requires diff --git a/spec/worker_spec.rb b/spec/worker_spec.rb index 223655f..3529fb0 100644 --- a/spec/worker_spec.rb +++ b/spec/worker_spec.rb @@ -57,6 +57,15 @@ def run_hook expect { run_hook }.not_to raise_error end + it 'still flushes our own log when the hook itself blows up' do + Leveret.configuration.before_child_exit = proc { raise 'sink unavailable' } + + expect do + run_hook + worker.send(:flush_own_log) + end.not_to raise_error + end + it 'gives up on a hanging hook instead of blocking the exit forever' do stub_const("#{described_class}::CHILD_EXIT_HOOK_TIMEOUT", 0.1) Leveret.configuration.before_child_exit = proc { sleep 5 } @@ -67,4 +76,34 @@ def run_hook expect(Time.now - started).to be < 2 end end + + # The child writes "Job returned ..." and "Exiting child process ..." microseconds before + # exit!, and a redirected STDOUT is block-buffered, so without this both are usually lost. + describe '#flush_own_log' do + # Built BEFORE the logger is stubbed: Worker.new logs while connecting to its queues, so a + # stub installed first would be exercised by construction rather than by the method itself. + let!(:worker) { Leveret::Worker.new } + + it "flushes the logger's underlying IO" do + io = StringIO.new + allow(Leveret).to receive(:log).and_return(Logger.new(io)) + expect(io).to receive(:flush).at_least(:once) + + worker.send(:flush_own_log) + end + + it 'does not raise when the logger exposes no flushable device' do + allow(Leveret).to receive(:log).and_return(double('logger')) + + expect { worker.send(:flush_own_log) }.not_to raise_error + end + + it 'does not raise when flushing itself fails' do + io = StringIO.new + allow(io).to receive(:flush).and_raise(IOError, 'stream closed') + allow(Leveret).to receive(:log).and_return(Logger.new(io)) + + expect { worker.send(:flush_own_log) }.not_to raise_error + end + end end From 5c56b217d7091e39de756f4f5e5cdfc6fa9642df Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 16 Sep 2026 08:37:31 +0200 Subject: [PATCH 3/5] Remove the accidentally committed Gemfile.lock Not tracked on master, and committed here only because it was generated by a local `bundle install` and swept up by `git add -A`. It does not belong in this PR on either count. It would also break CI. .travis.yml runs Ruby 2.2.3, and the generated lockfile pins rake 13.4.2, which requires Ruby >= 2.3 -- so bundler would install the incompatible pin instead of resolving a compatible release, and dependency installation would fail before any spec ran. A library should not ship an application-style lockfile regardless; it pins resolution for every consumer. Caught by Codex review. Co-Authored-By: Claude Opus 5 (1M context) --- Gemfile.lock | 47 ----------------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 Gemfile.lock diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 129a791..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,47 +0,0 @@ -PATH - remote: . - specs: - leveret (0.1.6) - bunny - json - -GEM - remote: https://rubygems.org/ - specs: - amq-protocol (2.7.0) - bunny (3.0.0) - amq-protocol (~> 2.7) - logger (~> 1, >= 1.7) - sorted_set (~> 1, >= 1.0.2) - diff-lcs (1.6.2) - json (3.0.2) - logger (1.7.0) - rake (13.4.2) - rbtree (0.4.7) - rspec (3.13.2) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.6) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.8) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.7) - sorted_set (1.1.0) - rbtree - -PLATFORMS - ruby - -DEPENDENCIES - bundler - leveret! - rake - rspec - -BUNDLED WITH - 2.1.4 From ab36222df3629a6b5798ae10b29525d793bab0b9 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 16 Sep 2026 08:45:01 +0200 Subject: [PATCH 4/5] Replace dead Travis config with GitHub Actions This repo has never run tests. The only CI config was a .travis.yml pinning Ruby 2.2.3 and bundler 1.10.6, and it was dead three times over: Travis was never connected to this fork (created 2026-08-20; a fork does not inherit the upstream's CI integration), travis-ci.org shut down in 2021, and Ruby 2.2.3 is long EOL. The PR that prompted this had zero check runs and zero registered workflows. The consequence was not theoretical. The suite could not even load -- `extend Forwardable` with nothing requiring forwardable -- and once that was fixed, 14 of 73 examples failed. Nobody could have known. Adds a workflow that runs rspec on two rubies: 2.7 (ubuntu-22.04) -- what DeployHQ runs today. Gates. 3.4 (ubuntu-24.04) -- where the app is heading. Advisory via continue-on-error, so a failure there cannot block a fix for the Ruby actually in production. Promote to required once consumers have moved. ubuntu-22.04 is required for 2.7 specifically: setup-ruby ships no 2.7 build for ubuntu-24.04. Two details the workflow has to get right: - The suite is NOT hermetic. spec_helper purges real queues through Bunny, so a RabbitMQ service container has to be healthy before any example runs. - The broker user is deliberately NOT `guest`. RabbitMQ restricts guest to loopback, and a service container is reached over the docker bridge, so guest authentication is refused. The workflow provisions a `leveret` user instead. That last point needs spec_helper to be configurable, so it now reads LEVERET_AMQP_URL and falls back to the same amqp://guest:guest@localhost:5672 developers already use. Local runs are unchanged; verified both paths. bundler-cache is off on purpose: this gem ships no Gemfile.lock, so there is no stable key to cache against, and the dependency set is small. Verified locally on 2.7.8 with and without LEVERET_AMQP_URL set: 73 examples, 0 failures both ways. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 64 ++++++++++++++++++++++++++++++++++++++++ .travis.yml | 4 --- spec/spec_helper.rb | 6 ++++ 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..22ce3e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + rspec: + name: rspec (ruby ${{ matrix.ruby }})${{ matrix.advisory && ' [advisory]' || '' }} + runs-on: ${{ matrix.os }} + # 3.4 reports but does not gate, so a failure there cannot block a fix for the 2.7 + # that actually runs in production. Promote it to required once consumers have moved. + continue-on-error: ${{ matrix.advisory || false }} + + strategy: + fail-fast: false + matrix: + include: + # The Ruby DeployHQ runs today. This one gates. + - ruby: '2.7' + os: ubuntu-22.04 # setup-ruby ships no 2.7 build for ubuntu-24.04 + # Where the app is heading; advisory until that upgrade lands. + - ruby: '3.4' + os: ubuntu-24.04 + advisory: true + + services: + rabbitmq: + # The suite is not hermetic -- spec_helper purges real queues through Bunny, so a + # broker has to be up before any example runs. + image: rabbitmq:3-management-alpine + ports: + - 5672:5672 + env: + # NOT guest. RabbitMQ restricts guest to loopback, and a service container is + # reached over the docker bridge, so guest authentication is refused. + RABBITMQ_DEFAULT_USER: leveret + RABBITMQ_DEFAULT_PASS: leveret + options: >- + --health-cmd "rabbitmq-diagnostics -q ping" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + # No bundler-cache: this gem deliberately ships no Gemfile.lock, so there is no + # stable key to cache against. The dependency set is small. + bundler-cache: false + + - name: Install dependencies + run: | + gem install bundler --no-document + bundle install --jobs 4 --retry 3 + + - name: rspec + env: + LEVERET_AMQP_URL: amqp://leveret:leveret@localhost:5672 + run: bundle exec rspec --format documentation diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6fb8618..0000000 --- a/.travis.yml +++ /dev/null @@ -1,4 +0,0 @@ -language: ruby -rvm: - - 2.2.3 -before_install: gem install bundler -v 1.10.6 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 99626ba..e057d0d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,8 @@ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) require 'leveret' +# Three spec files build doubles with OpenStruct. ostruct is no longer loaded implicitly, so +# without this every example in those files errors before it runs -- 14 of them. +require 'ostruct' Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each { |f| require f } @@ -8,6 +11,9 @@ c.before(:all) do Leveret.configure do |conf| + # Overridable so CI can point at a broker that does not accept the loopback-only + # `guest` account. Defaults to the same local broker developers already use. + conf.amqp = ENV.fetch('LEVERET_AMQP_URL', 'amqp://guest:guest@localhost:5672') conf.log_level = Logger::ERROR conf.queue_name_prefix = 'leveret_test_queue' conf.default_queue_name = 'test' From 0e6d3a7b000ca0c4cf1263874199ff5f1513d871 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 16 Sep 2026 08:56:20 +0200 Subject: [PATCH 5/5] Pin bundler per Ruby so the 2.7 job can install it The 2.7 job failed at Install dependencies, before a single spec ran: The last version of bundler (>= 0) to support your Ruby & RubyGems was 2.4.22 bundler requires Ruby version >= 3.2.0. The current ruby version is 2.7.8.225 `gem install bundler` with no constraint resolves to the newest release, which now requires Ruby >= 3.2. On 2.7 it cannot install at all. Pin it per Ruby through setup-ruby's own `bundler` input rather than a bare `gem install`, so the version is fixed by the matrix instead of resolved at run time: 2.4.22 on 2.7, latest on 3.4. That 2.4.22 is the same pin the consuming app documents as the last line supporting Ruby 2.7 -- and specifically not 1.17.3, which has a default-gem activation bug that has caused production deploy outages there. Also prints `bundle --version` before installing, so the next failure of this shape is one line of log rather than an inference. The 3.4 job already passed on the previous run -- 73 examples, 0 failures against the RabbitMQ service container -- so the service, the non-guest broker user and LEVERET_AMQP_URL are all confirmed working. Only bundler was wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22ce3e3..b9584d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,15 @@ jobs: # The Ruby DeployHQ runs today. This one gates. - ruby: '2.7' os: ubuntu-22.04 # setup-ruby ships no 2.7 build for ubuntu-24.04 + # 2.4.22 is the last bundler line that supports Ruby 2.7; anything newer + # requires >= 3.2 and fails to install. This is the same pin the consuming + # app documents. Do NOT drop to 1.17.3 -- it has a default-gem activation + # bug that has caused production deploy outages there. + bundler: '2.4.22' # Where the app is heading; advisory until that upgrade lands. - ruby: '3.4' os: ubuntu-24.04 + bundler: 'latest' advisory: true services: @@ -49,13 +55,17 @@ jobs: - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} + # Let setup-ruby install bundler, so the version is pinned per Ruby (see matrix) + # rather than resolved at run time. A bare `gem install bundler` picks the newest + # release, which on 2.7 is one that refuses to install. + bundler: ${{ matrix.bundler }} # No bundler-cache: this gem deliberately ships no Gemfile.lock, so there is no # stable key to cache against. The dependency set is small. bundler-cache: false - name: Install dependencies run: | - gem install bundler --no-document + bundle --version bundle install --jobs 4 --retry 3 - name: rspec