Skip to content

after_schedule hook is called during handle_delayed_items re-enqueue #810

@top-sigrid

Description

@top-sigrid

Description

When resque-scheduler processes delayed jobs via handle_delayed_items (moving them from the delayed queue to the regular Resque queue), it calls both before_delayed_enqueue and after_schedule hooks. This is unexpected because after_schedule should only be called when a job is initially scheduled, not when it's being moved to the regular queue. That's also what the Readme states.

Expected Behavior

This is what I would say is expected:

  • after_schedule should be called once when a job is scheduled via Resque.enqueue_at
  • before_delayed_enqueue should be called once when the job is moved from the delayed queue to the regular queue
  • These hooks should be mutually exclusive - a job is either being scheduled or being enqueued

It could of course also be that I am misunderstanding or missing something.

Actual Behavior

When handle_delayed_items processes a delayed job:

  1. before_delayed_enqueue is called
  2. after_schedule is also called (incorrect - the job is not being scheduled, it's being enqueued)

Root Cause

The issue as I see it is in enqueue_from_config (scheduler.rb:334) which wraps the enqueue operation in process_schedule_hooks:

Resque::Scheduler::Plugin.process_schedule_hooks(klass, *params) do
  Resque::Scheduler::Plugin.run_before_delayed_enqueue_hooks(klass, *params)
  # ... enqueue job
end

and process_schedule_hooks (plugin.rb:6-13) unconditionally calls run_after_schedule_hooks:

  def self.process_schedule_hooks(klass, *args)
    return unless run_before_schedule_hooks(klass, *args)
    yield
    run_after_schedule_hooks(klass, *args)
  end

Reproduction Script

#!/usr/bin/env ruby
#
# Standalone script to demonstrate a bug in resque-scheduler where the
# `after_schedule` hook is called during `handle_delayed_items`.
#
# Prerequisites:
#   Redis running on localhost:6379
#
# Run with: ruby <filename.rb>

require "bundler/inline"

gemfile do
  source "https://rubygems.org"
  gem "resque", "~> 2.6"
  gem "resque-scheduler", "~> 4.11"
end

require "resque"
require "resque-scheduler"

redis = Redis.new(db: 15)
Resque.redis = redis
redis.flushdb

# Test job that tracks hook calls
class TestHookJob
  @queue = :default
  @hook_counts = {after_schedule: 0, before_delayed_enqueue: 0}

  class << self
    attr_reader :hook_counts
  end

  def self.perform(*args)
    puts "Job performed with args: #{args.inspect}"
  end

  def self.after_schedule(*args)
    @hook_counts[:after_schedule] += 1
    puts "HOOK: after_schedule called (count: #{@hook_counts[:after_schedule]})"
  end

  def self.before_delayed_enqueue(*args)
    @hook_counts[:before_delayed_enqueue] += 1
    puts "HOOK: before_delayed_enqueue called (count: #{@hook_counts[:before_delayed_enqueue]})"
  end
end

puts "=== Step 1: Schedule a job 1 hour from now ==="
Resque.enqueue_at(Time.now + 3600, TestHookJob, "test_arg")
puts ""

puts "=== Step 2: Simulate time passing and process delayed items ==="
# ########
# Expected: only before_delayed_enqueue should run here.
# Actual: after_schedule is being called again (bug).
# ########
Resque::Scheduler.handle_delayed_items(Time.now + 4000)
puts ""

puts "=== Summary ==="
puts "Hook call counts:"
puts "  after_schedule:         #{TestHookJob.hook_counts[:after_schedule]} (expected: 1)"
puts "  before_delayed_enqueue: #{TestHookJob.hook_counts[:before_delayed_enqueue]} (expected: 1)"

# Cleanup
redis.flushdb

Output:

=== Step 1: Schedule a job 1 hour from now ===
HOOK: after_schedule called (count: 1)

=== Step 2: Simulate time passing and process delayed items ===
resque-scheduler: [INFO] 2025-12-13T10:37:53+01:00: Processing Delayed Items
HOOK: before_delayed_enqueue called (count: 1)
HOOK: after_schedule called (count: 2)

=== Summary ===
Hook call counts:
  after_schedule:         2 (expected: 1)
  before_delayed_enqueue: 1 (expected: 1)

I ran this with:

  • resque-scheduler version: 4.11.0
  • resque version: 2.7.0
  • ruby version: 3.4.4

Suggested Fix

I would say enqueue_from_config should not call process_schedule_hooks since the job is being enqueued, not scheduled. It should only call run_before_delayed_enqueue_hooks without the wrapping process_schedule_hooks.

As probably many projects already rely on the hooks being this way, there could as well be a configuration flag

Resque::Scheduler.configure do |config|
  config.skip_after_schedule_on_enqueue = true  # defaults to false
end

Also maybe a separate after_delayed_enqueue hook could be the solution, additionally to the current after_schedule and before_delayed_enqueue.

Let me know what you think. Maybe this is intended and/or I might misunderstand or miss something.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions