From d0809067590a1fa9fb33711d2830e6170b0bab8d Mon Sep 17 00:00:00 2001 From: Philipp Thun Date: Tue, 11 Aug 2026 18:02:13 +0200 Subject: [PATCH] Prevent ThreadedWorker from leaking enqueue lifecycle callbacks Delayed::Plugin callbacks register against Delayed::Worker.lifecycle (the base class), and Delayed::Job.enqueue runs that same base lifecycle. Delayed::Worker#initialize calls self.class.setup_lifecycle, which rebuilds the lifecycle on the class it is called on. Since ThreadedWorker is a subclass, setup_lifecycle reset a different lifecycle than the base one the plugins append to. The base lifecycle was therefore never reset, and every ThreadedWorker.new added another before(:enqueue) callback to it. The before(:enqueue) callback creates a PollableJobModel row, so the accumulation caused a single enqueue to create many pollable rows. Production is unaffected since a worker is created once per process. Delegate setup_lifecycle and lifecycle on ThreadedWorker to the base class so each instantiation rebuilds the same lifecycle cleanly. --- lib/delayed_job/threaded_worker.rb | 13 +++++++++++++ spec/unit/lib/delayed_job/threaded_worker_spec.rb | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/delayed_job/threaded_worker.rb b/lib/delayed_job/threaded_worker.rb index 3f514daaf7b..a6ab1d6c7b9 100644 --- a/lib/delayed_job/threaded_worker.rb +++ b/lib/delayed_job/threaded_worker.rb @@ -1,5 +1,18 @@ module Delayed class ThreadedWorker < Delayed::Worker + # Delayed::Plugin callbacks register against the base Delayed::Worker.lifecycle, + # but setup_lifecycle rebuilds the lifecycle on the class it is called on. On this + # subclass it would reset a different lifecycle than the one plugins append to, + # so every ThreadedWorker.new would leak another callback into the base lifecycle. + # Delegate to the base class so each instantiation rebuilds the same lifecycle. + def self.setup_lifecycle + Delayed::Worker.setup_lifecycle + end + + def self.lifecycle + Delayed::Worker.lifecycle + end + def initialize(options={}) super @num_threads = options[:num_threads] diff --git a/spec/unit/lib/delayed_job/threaded_worker_spec.rb b/spec/unit/lib/delayed_job/threaded_worker_spec.rb index f425a1b38a3..285bc5ecfbf 100644 --- a/spec/unit/lib/delayed_job/threaded_worker_spec.rb +++ b/spec/unit/lib/delayed_job/threaded_worker_spec.rb @@ -22,6 +22,16 @@ worker = Delayed::ThreadedWorker.new({ num_threads: 2 }) expect(worker.instance_variable_get(:@grace_period_seconds)).to eq(30) end + + it 'does not accumulate enqueue lifecycle callbacks across instantiations' do + before_enqueue_callbacks = lambda do + Delayed::Worker.lifecycle.instance_variable_get(:@callbacks)[:enqueue].instance_variable_get(:@before).size + end + + baseline = before_enqueue_callbacks.call + 5.times { Delayed::ThreadedWorker.new(options) } + expect(before_enqueue_callbacks.call).to eq(baseline) + end end describe '#start' do