Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add random offset capabilities to desynchronize parallel workers #615

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ every :sunday, :at => '12pm' do # Use any day of the week or :weekend, :weekday
runner "Task.do_something_great"
end

every(3.hours).and_about(5.minutes) do # Add a random element to avoid having many instances fire at the same time
# equivalently pass a :random_offset option
runner "CentralizedServer.pull_updates"
end

every '0 0 27-31 * *' do
command "echo 'you can use raw cron syntax too'"
end
Expand Down
13 changes: 13 additions & 0 deletions lib/whenever/job.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'shellwords'
require 'whenever/random_offset'

module Whenever
class Job
Expand All @@ -10,6 +11,7 @@ def initialize(options = {})
@template = options.delete(:template)
@job_template = options.delete(:job_template) || ":job"
@roles = Array(options.delete(:roles))
@random_offset = options.delete(:random_offset) || 0
@options[:output] = options.has_key?(:output) ? Whenever::Output::Redirection.new(options[:output]).to_s : ''
@options[:environment_variable] ||= "RAILS_ENV"
@options[:environment] ||= :production
Expand All @@ -19,6 +21,7 @@ def initialize(options = {})
def output
job = process_template(@template, @options)
out = process_template(@job_template, @options.merge(:job => job))
out = apply_random_offset(out)
out.gsub(/%/, '\%')
end

Expand All @@ -28,6 +31,16 @@ def has_role?(role)

protected

def apply_random_offset(templated_job)
if @random_offset > 0
random_sleep_expr = Whenever::RandomOffset.sleep_expression(@random_offset)
templated_sleep_job = process_template(@job_template, :job => random_sleep_expr)
[templated_sleep_job, templated_job].join(' && ')
else
templated_job
end
end

def process_template(template, options)
template.gsub(/:\w+/) do |key|
before_and_after = [$`[-1..-1], $'[0..0]]
Expand Down
5 changes: 5 additions & 0 deletions lib/whenever/job_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ def env(variable, value)
def every(frequency, options = {})
@current_time_scope = frequency
@options = options
block_given? ? yield : self
end

def and_about(time)
(@options ||= {})[:random_offset] = time
yield
end

Expand Down
19 changes: 19 additions & 0 deletions lib/whenever/random_offset.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module Whenever
class RandomOffset
RANDOM_MAX = 32767 # max value for bash $Random. 15 bits of entropy ought to be plenty for this purpose

# Bash random number generator. Given 5, will return a random number from 0 to 10, with uniform probability.
# For ranges exceeding 2^15 seconds (9 hours), we use multiplication to get the random number into the right range.
# This is not great randomness, but it still gives 2^15 possible results so it should be fine for the intended purpose.
def self.sleep_expression(center)
maximum = center * 2 + 1
multiplier = maximum.fdiv(RANDOM_MAX).ceil
if multiplier > 1
"sleep $(expr ($RANDOM * #{multiplier}) % #{maximum})"
else
"sleep $(expr $RANDOM % #{maximum})"
end
end

end
end
36 changes: 36 additions & 0 deletions test/functional/output_at_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ class OutputAtTest < Whenever::TestCase
assert_match '2 5 * * 1-5 blahblah', output
end

test "weekday at a (single) given time with offset" do
output = Whenever.cron \
<<-file
set :job_template, nil
every("weekday", :at => '5:02am').and_about(5.minutes) do
command "blahblah"
end
file

assert_match '2 5 * * 1-5 sleep $(expr $RANDOM \% 601) && blahblah', output
end

test "weekday at a multiple diverse times, via an array" do
output = Whenever.cron \
<<-file
Expand Down Expand Up @@ -193,6 +205,30 @@ class OutputAtTest < Whenever::TestCase
assert_match '27,29,31,33,35,37,39,41,43,45,47,49,51,53,55,57,59 * * * * blahblah', output
end

test "every hour but staggered" do
output = Whenever.cron \
<<-file
set :job_template, nil
every(1.hour).and_about(10.minutes) do
command "blahblah"
end
file

assert_match '0 * * * * sleep $(expr $RANDOM \% 1201) && blahblah', output
end

test "every week but staggered using random_offset syntax" do
output = Whenever.cron \
<<-file
set :job_template, nil
every 1.week, random_offset: 1.day do
command "blahblah"
end
file

assert_match '0 0 1,8,15,22 * * sleep $(expr ($RANDOM * 6) \% 172801) && blahblah', output
end

test "using raw cron syntax" do
output = Whenever.cron \
<<-file
Expand Down
10 changes: 10 additions & 0 deletions test/unit/job_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ class JobTest < Whenever::TestCase
assert_equal 'Hello :world', new_job(:template => ':matching :world', :matching => 'Hello').output
end

should "apply the random offset in the output" do
job = new_job(:template => ":task", :task => 'abc123', :random_offset => 5)
assert_equal 'sleep $(expr $RANDOM \% 11) && abc123', job.output
end

should "ignore a random offset of 0" do
job = new_job(:template => ":task", :task => 'abc123', :random_offset => 0)
assert_equal 'abc123', job.output
end

should "escape the :path" do
assert_equal '/my/spacey\ path', new_job(:template => ':path', :path => '/my/spacey path').output
end
Expand Down
12 changes: 12 additions & 0 deletions test/unit/random_offset_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
require 'test_helper'

class RandomOffsetTest < Whenever::TestCase
should "generate a random sleep using bash" do
assert_equal 'sleep $(expr $RANDOM % 9)', Whenever::RandomOffset.sleep_expression(4)
end

should "handle large input" do
assert_equal 'sleep $(expr ($RANDOM * 6) % 172801)', Whenever::RandomOffset.sleep_expression(1.day)
end

end