Skip to content

after_schedule hook is called during handle_delayed_items re-enqueue #810

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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions