From 2c7c1bad56309445a493583fe80c51f16e3e9865 Mon Sep 17 00:00:00 2001 From: Aaron Weiner Date: Thu, 28 Apr 2016 14:28:19 -0400 Subject: [PATCH] Add random offset capabilities to desynchronize parallel workers --- README.md | 5 +++++ lib/whenever/job.rb | 13 +++++++++++ lib/whenever/job_list.rb | 5 +++++ lib/whenever/random_offset.rb | 19 ++++++++++++++++ test/functional/output_at_test.rb | 36 +++++++++++++++++++++++++++++++ test/unit/job_test.rb | 10 +++++++++ test/unit/random_offset_test.rb | 12 +++++++++++ 7 files changed, 100 insertions(+) create mode 100644 lib/whenever/random_offset.rb create mode 100644 test/unit/random_offset_test.rb diff --git a/README.md b/README.md index d56f36d7..e215612c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/whenever/job.rb b/lib/whenever/job.rb index 1652c8af..69e03c8c 100644 --- a/lib/whenever/job.rb +++ b/lib/whenever/job.rb @@ -1,4 +1,5 @@ require 'shellwords' +require 'whenever/random_offset' module Whenever class Job @@ -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 @@ -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 @@ -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]] diff --git a/lib/whenever/job_list.rb b/lib/whenever/job_list.rb index 76b358fc..7ee49de5 100644 --- a/lib/whenever/job_list.rb +++ b/lib/whenever/job_list.rb @@ -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 diff --git a/lib/whenever/random_offset.rb b/lib/whenever/random_offset.rb new file mode 100644 index 00000000..810d826d --- /dev/null +++ b/lib/whenever/random_offset.rb @@ -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 diff --git a/test/functional/output_at_test.rb b/test/functional/output_at_test.rb index 7bced9ab..8081f40d 100644 --- a/test/functional/output_at_test.rb +++ b/test/functional/output_at_test.rb @@ -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 @@ -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 diff --git a/test/unit/job_test.rb b/test/unit/job_test.rb index a9e34ce5..adfe4341 100644 --- a/test/unit/job_test.rb +++ b/test/unit/job_test.rb @@ -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 diff --git a/test/unit/random_offset_test.rb b/test/unit/random_offset_test.rb new file mode 100644 index 00000000..100b1277 --- /dev/null +++ b/test/unit/random_offset_test.rb @@ -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