ruby-concurrency/concurrent-ruby · error · ArgumentError

seconds must be greater than zero

Error message

seconds must be greater than zero

What it means

ScheduledTask's delay is a relative offset in seconds, converted with #to_f and rejected when negative because a negative delay means the target time is already in the past. Note the mismatch between message and code: the check is delay.to_f < 0.0, so zero (and values like nil or 'abc' whose to_f is 0.0) is silently accepted and runs immediately. Passing an absolute Time object also slips through, since Time#to_f becomes a huge positive epoch offset.

Source

Thrown at lib/concurrent-ruby/concurrent/scheduled_task.rb:180

    # @!visibility private
    attr_reader :executor

    # Schedule a task for execution at a specified future time.
    #
    # @param [Float] delay the number of seconds to wait for before executing the task
    #
    # @yield the task to be performed
    #
    # @!macro executor_and_deref_options
    #
    # @option opts [object, Array] :args zero or more arguments to be passed the task
    #   block on execution
    #
    # @raise [ArgumentError] When no block is given
    # @raise [ArgumentError] When given a time that is in the past
    def initialize(delay, opts = {}, &task)
      raise ArgumentError.new('no block given') unless block_given?
      raise ArgumentError.new('seconds must be greater than zero') if delay.to_f < 0.0

      super(NULL, opts, &nil)

      synchronize do
        ns_set_state(:unscheduled)
        @parent = opts.fetch(:timer_set, Concurrent.global_timer_set)
        @args = get_arguments_from(opts)
        @delay = delay.to_f
        @task = task
        @time = nil
        @executor = Options.executor_from_options(opts) || Concurrent.global_io_executor
        self.observers = Collection::CopyOnNotifyObserverSet.new
      end
    end

    # The `delay` value given at instantiation.
    #
    # @return [Float] the initial delay.

View on GitHub (pinned to 0b88d5ff75)

Solutions

  1. Clamp computed delays at the call site: delay = [run_at.to_f - Time.now.to_f, 0].max
  2. Treat 'deadline already passed' as a business decision: if run_at <= Time.now, run the work synchronously or skip it, then pass the clamped delay
  3. Reject nil explicitly before calling new if an immediate fire is not intended: raise ArgumentError, 'delay missing' if delay.nil?
  4. Never pass a Time object; convert it to a relative offset first

Example fix

# before (raises when deadline has passed)
task = Concurrent::ScheduledTask.new(deadline - Time.now) { fire_alarm }

# after (past deadlines run immediately)
delay = [[deadline - Time.now, 0].max, 0].max
task = Concurrent::ScheduledTask.new(delay) { fire_alarm }
Defensive patterns

Strategy: validation

Validate before calling

delay = delay.to_f
delay = 0.0 if delay < 0 # past deadlines fire immediately
raise ArgumentError, 'delay required' if delay.nil?
Concurrent::ScheduledTask.new(delay) { work }

Type guard

def valid_delay?(v)
  v.is_a?(Numeric) && !v.nil? && v.to_f >= 0
end

Try / catch

begin
  task = Concurrent::ScheduledTask.new(delay) { work }
rescue ArgumentError => e
  raise if e.message != 'seconds must be greater than zero'
  delay = 0
  task = Concurrent::ScheduledTask.new(delay) { work } # deadline already passed: run now
end

Prevention

When it happens

Trigger: Computing the delay as run_at - Time.now where run_at is already past; passing '-30' (a negative numeric string); passing nil (nil.to_f == 0.0 passes, task fires immediately); passing a Time object instead of a seconds offset.

Common situations: Scheduling jobs at absolute timestamps taken from a DB or queue; clock skew between the app server and the system that produced the timestamp; DST transitions making a computed offset negative; config values that are absent and become nil.

Related errors


AI-assisted analysis of ruby-concurrency/concurrent-ruby@0b88d5ff75 (2026-08-21). Data as JSON: /api/errors/27810eabf9dd11ee. Report an issue: GitHub.