zhong 0.1.4 → 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,16 +3,39 @@ module Zhong
3
3
  class FailedToParse < StandardError; end
4
4
 
5
5
  EVERY_KEYWORDS = {
6
+ minute: 1.minute,
7
+ hour: 1.hour,
6
8
  day: 1.day,
7
9
  week: 1.week,
8
10
  month: 1.month,
9
- semiannual: 6.months, # enterprise!
10
11
  year: 1.year,
11
12
  decade: 10.years
12
13
  }.freeze
13
14
 
14
15
  def initialize(period)
15
16
  @period = period
17
+
18
+ fail "`every` must be >= 1 second" unless valid?
19
+ end
20
+
21
+ def to_s
22
+ EVERY_KEYWORDS.to_a.reverse.each do |friendly, period|
23
+ if @period % period == 0
24
+ rem = @period / period
25
+
26
+ if rem == 1
27
+ return "#{rem} #{friendly}"
28
+ else
29
+ return "#{rem} #{friendly}s"
30
+ end
31
+ end
32
+ end
33
+
34
+ "#{@period.to_i} second#{@period.to_i == 1 ? '' : 's'}"
35
+ end
36
+
37
+ private def valid?
38
+ @period.to_f >= 1
16
39
  end
17
40
 
18
41
  def next_at(last = Time.now)
@@ -20,8 +43,6 @@ module Zhong
20
43
  end
21
44
 
22
45
  def self.parse(every)
23
- return unless every
24
-
25
46
  case every
26
47
  when Numeric, ActiveSupport::Duration
27
48
  new(every)
@@ -1,95 +1,90 @@
1
1
  module Zhong
2
2
  class Job
3
- attr_reader :name, :category
3
+ attr_reader :name, :category, :last_ran, :logger, :at, :every, :id
4
4
 
5
- def initialize(name, config = {}, &block)
6
- @name = name
5
+ def initialize(job_name, config = {}, &block)
6
+ @name = job_name
7
7
  @category = config[:category]
8
+ @logger = config[:logger]
9
+ @config = config
8
10
 
9
- @at = At.parse(config[:at], grace: config.fetch(:grace, 15.minutes))
10
- @every = Every.parse(config[:every])
11
-
12
- if @at && !@every
13
- @logger.error "warning: #{self} has `at` but no `every`; could run far more often than expected!"
14
- end
11
+ @at = config[:at] ? At.parse(config[:at], grace: config.fetch(:grace, 15.minutes)) : nil
12
+ @every = config[:every] ? Every.parse(config[:every]) : nil
15
13
 
16
14
  fail "must specific either `at` or `every` for job: #{self}" unless @at || @every
17
15
 
18
16
  @block = block
19
17
 
20
18
  @redis = config[:redis]
21
- @logger = config[:logger]
22
19
  @tz = config[:tz]
23
20
  @if = config[:if]
24
- @lock = Suo::Client::Redis.new(lock_key, client: @redis, stale_lock_expiration: config[:long_running_timeout])
25
- @timeout = 5
26
-
27
- refresh_last_ran
21
+ @long_running_timeout = config[:long_running_timeout]
22
+ @running = false
23
+ @first_run = true
24
+ @id = Digest::SHA256.hexdigest(@name)
28
25
  end
29
26
 
30
27
  def run?(time = Time.now)
28
+ if @first_run
29
+ clear_last_ran_if_at_changed if @at
30
+ refresh_last_ran
31
+ @first_run = false
32
+ end
33
+
31
34
  run_every?(time) && run_at?(time) && run_if?(time)
32
35
  end
33
36
 
34
37
  def run(time = Time.now, error_handler = nil)
35
38
  return unless run?(time)
36
39
 
37
- if running?
38
- @logger.info "already running: #{self}"
39
- return
40
- end
41
-
42
- @thread = nil
43
40
  locked = false
41
+ errored = false
44
42
 
45
- @lock.lock do
46
- locked = true
43
+ begin
44
+ redis_lock.lock do
45
+ locked = true
46
+ @running = true
47
47
 
48
- refresh_last_ran
48
+ refresh_last_ran
49
49
 
50
- # we need to check again, as another process might have acquired
51
- # the lock right before us and obviated our need to do anything
52
- break unless run?(time)
50
+ # we need to check again, as another process might have acquired
51
+ # the lock right before us and obviated our need to do anything
52
+ break unless run?(time)
53
53
 
54
- if disabled?
55
- @logger.info "disabled: #{self}"
56
- break
57
- end
54
+ if disabled?
55
+ logger.info "not running, disabled: #{self}"
56
+ break
57
+ end
58
58
 
59
- @logger.info "running: #{self}"
59
+ logger.info "running: #{self}"
60
60
 
61
- if @block
62
- @thread = Thread.new do
61
+ if @block
63
62
  begin
64
63
  @block.call
65
64
  rescue => boom
66
- @logger.error "#{self} failed: #{boom}"
65
+ logger.error "#{self} failed: #{boom}"
67
66
  error_handler.call(boom, self) if error_handler
68
67
  end
69
-
70
- nil # do not retain thread return value
71
68
  end
72
- end
73
69
 
74
- ran!(time)
70
+ ran!(time)
71
+ end
72
+ rescue Suo::LockClientError => boom
73
+ logger.error "unable to run due to client error: #{boom}"
74
+ errored = true
75
75
  end
76
76
 
77
- @logger.info "unable to acquire exclusive run lock: #{self}" unless locked
78
- end
77
+ @running = false
79
78
 
80
- def stop
81
- return unless running?
82
- Thread.new { @logger.error "killing #{self} due to stop" } # thread necessary due to trap context
83
- @thread.join(@timeout)
84
- @thread.kill
79
+ logger.info "unable to acquire exclusive run lock: #{self}" if !locked && !errored
85
80
  end
86
81
 
87
82
  def running?
88
- @thread && @thread.alive?
83
+ @running
89
84
  end
90
85
 
91
86
  def refresh_last_ran
92
- last_ran_val = @redis.get(run_time_key)
87
+ last_ran_val = @redis.get(last_ran_key)
93
88
  @last_ran = last_ran_val ? Time.at(last_ran_val.to_i) : nil
94
89
  end
95
90
 
@@ -106,7 +101,7 @@ module Zhong
106
101
  end
107
102
 
108
103
  def to_s
109
- [@category, @name].compact.join(".").freeze
104
+ @to_s ||= [@category, @name].compact.join(".").freeze
110
105
  end
111
106
 
112
107
  def next_at
@@ -115,8 +110,45 @@ module Zhong
115
110
  [every_time, at_time, Time.now].compact.max || "now"
116
111
  end
117
112
 
113
+ def clear
114
+ @redis.del(last_ran_key)
115
+ end
116
+
117
+ def last_ran_key
118
+ "zhong:last_ran:#{self}"
119
+ end
120
+
121
+ def desired_at_key
122
+ "zhong:at:#{self}"
123
+ end
124
+
125
+ def disabled_key
126
+ "zhong:disabled:#{self}"
127
+ end
128
+
129
+ def lock_key
130
+ "zhong:lock:#{self}"
131
+ end
132
+
118
133
  private
119
134
 
135
+ # if the @at value is changed across runs, the last_run becomes invalid
136
+ # so clear it
137
+ def clear_last_ran_if_at_changed
138
+ previous_at_msgpack = @redis.get(desired_at_key)
139
+
140
+ if previous_at_msgpack
141
+ previous_at = At.deserialize(previous_at_msgpack)
142
+
143
+ if previous_at != @at
144
+ logger.error "#{self} period changed (from #{previous_at} to #{@at}), clearing last run"
145
+ clear
146
+ end
147
+ end
148
+
149
+ @redis.set(desired_at_key, @at.serialize)
150
+ end
151
+
120
152
  def run_every?(time)
121
153
  !@last_ran || !@every || @every.next_at(@last_ran) <= time
122
154
  end
@@ -131,19 +163,11 @@ module Zhong
131
163
 
132
164
  def ran!(time)
133
165
  @last_ran = time
134
- @redis.set(run_time_key, @last_ran.to_i)
135
- end
136
-
137
- def run_time_key
138
- "zhong:last_ran:#{self}"
166
+ @redis.set(last_ran_key, @last_ran.to_i)
139
167
  end
140
168
 
141
- def disabled_key
142
- "zhong:disabled:#{self}"
143
- end
144
-
145
- def lock_key
146
- "zhong:lock:#{self}"
169
+ def redis_lock
170
+ @lock ||= Suo::Client::Redis.new(lock_key, client: @redis, stale_lock_expiration: @long_running_timeout)
147
171
  end
148
172
  end
149
173
  end
@@ -5,17 +5,20 @@ module Zhong
5
5
  DEFAULT_CONFIG = {
6
6
  timeout: 0.5,
7
7
  grace: 15.minutes,
8
- long_running_timeout: 5.minutes
8
+ long_running_timeout: 5.minutes,
9
+ tz: nil
9
10
  }.freeze
10
11
 
11
- TRAPPED_SIGNALS = %w(QUIT INT TERM).freeze
12
-
13
12
  def initialize(config = {})
14
13
  @jobs = {}
15
14
  @callbacks = {}
16
15
  @config = DEFAULT_CONFIG.merge(config)
17
- @logger = @config[:logger] ||= Util.default_logger
18
- @redis = @config[:redis] ||= Redis.new(ENV["REDIS_URL"])
16
+
17
+ @logger = @config[:logger]
18
+ @redis = @config[:redis]
19
+ @tz = @config[:tz]
20
+ @category = nil
21
+ @error_handler = nil
19
22
  end
20
23
 
21
24
  def category(name)
@@ -30,8 +33,15 @@ module Zhong
30
33
 
31
34
  def every(period, name, opts = {}, &block)
32
35
  fail "must specify a period for #{name} (#{caller.first})" unless period
36
+
33
37
  job = Job.new(name, opts.merge(@config).merge(every: period, category: @category), &block)
34
- add(job)
38
+
39
+ if jobs.key?(job.id)
40
+ @logger.error "duplicate job #{job}, skipping"
41
+ return
42
+ end
43
+
44
+ @jobs[job.id] = job
35
45
  end
36
46
 
37
47
  def error_handler(&block)
@@ -45,64 +55,86 @@ module Zhong
45
55
  end
46
56
 
47
57
  def start
48
- TRAPPED_SIGNALS.each do |sig|
49
- Signal.trap(sig) { stop }
50
- end
51
-
52
58
  @logger.info "starting at #{redis_time}"
53
59
 
60
+ @stop = false
61
+
62
+ trap_signals
63
+
54
64
  loop do
55
65
  if fire_callbacks(:before_tick)
56
66
  now = redis_time
57
67
 
58
- jobs.each do |_, job|
59
- if fire_callbacks(:before_run, job, now)
60
- job.run(now, error_handler)
61
- fire_callbacks(:after_run, job, now)
62
- end
68
+ jobs_to_run(now).each do |_, job|
69
+ break if @stop
70
+ run_job(job, now)
63
71
  end
64
72
 
73
+ break if @stop
74
+
65
75
  fire_callbacks(:after_tick)
66
76
 
67
- GC.start
77
+ heartbeat(now)
68
78
 
69
- sleep(interval)
79
+ break if @stop
80
+ sleep_until_next_second
70
81
  end
71
82
 
72
83
  break if @stop
73
84
  end
85
+
86
+ Thread.new { @logger.info "stopped" }.join
74
87
  end
75
88
 
76
89
  def stop
77
90
  Thread.new { @logger.error "stopping" } # thread necessary due to trap context
78
91
  @stop = true
79
- jobs.values.each(&:stop)
80
- Thread.new { @logger.info "stopped" }
81
92
  end
82
93
 
94
+ private
95
+
96
+ TRAPPED_SIGNALS = %w(QUIT INT TERM).freeze
97
+ private_constant :TRAPPED_SIGNALS
98
+
83
99
  def fire_callbacks(event, *args)
84
100
  @callbacks[event].to_a.all? { |h| h.call(*args) }
85
101
  end
86
102
 
87
- private
103
+ def jobs_to_run(time = redis_time)
104
+ jobs.select { |_, job| job.run?(time) }
105
+ end
88
106
 
89
- def add(job)
90
- if @jobs.key?(job.to_s)
91
- @logger.error "duplicate job #{job}, skipping"
92
- return
93
- end
107
+ def run_job(job, time = redis_time)
108
+ return unless fire_callbacks(:before_run, job, time)
109
+
110
+ job.run(time, error_handler)
94
111
 
95
- @jobs[job.to_s] = job
112
+ fire_callbacks(:after_run, job, time)
113
+ end
114
+
115
+ def heartbeat(time)
116
+ @redis.setex(heartbeat_key, @config[:grace].to_i, time.to_i)
117
+ end
118
+
119
+ def heartbeat_key
120
+ @heartbeat_key ||= "zhong:heartbeat:#{`hostname`.strip}##{Process.pid}"
121
+ end
122
+
123
+ def trap_signals
124
+ TRAPPED_SIGNALS.each do |sig|
125
+ Signal.trap(sig) { stop }
126
+ end
96
127
  end
97
128
 
98
- def interval
99
- 1.0 - Time.now.subsec + 0.001
129
+ def sleep_until_next_second
130
+ GC.start
131
+ sleep(1.0 - Time.now.subsec + 0.0001)
100
132
  end
101
133
 
102
134
  def redis_time
103
135
  s, ms = @redis.time # returns [seconds since epoch, microseconds]
104
136
  now = Time.at(s + ms / (10**6))
105
- config[:tz] ? now.in_time_zone(config[:tz]) : now
137
+ @tz ? now.in_time_zone(@tz) : now
106
138
  end
107
139
  end
108
140
  end
@@ -1,3 +1,3 @@
1
1
  module Zhong
2
- VERSION = "0.1.4"
2
+ VERSION = "0.1.5"
3
3
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+ require "tilt/erubis"
3
+ require "erb"
4
+ require "sinatra/base"
5
+
6
+ require "zhong"
7
+ require "zhong/web_helpers"
8
+
9
+ module Zhong
10
+ class Web < Sinatra::Base
11
+ enable :sessions
12
+ use ::Rack::Protection, use: :authenticity_token unless ENV["RACK_ENV"] == "test"
13
+
14
+ if ENV["ZHONG_WEB_USERNAME"] && ENV["ZHONG_WEB_PASSWORD"]
15
+ use Rack::Auth::Basic, "Sorry." do |username, password|
16
+ username == ENV["ZHONG_WEB_USERNAME"] and password == ENV["ZHONG_WEB_PASSWORD"]
17
+ end
18
+ end
19
+
20
+ if ENV["RACK_ENV"] == "development"
21
+ before do
22
+ STDERR.puts "[params] #{params}" unless params.empty?
23
+ end
24
+ end
25
+
26
+ set :root, File.expand_path(File.dirname(__FILE__) + "/../../web")
27
+ set :public_folder, proc { "#{root}/assets" }
28
+ set :views, proc { "#{root}/views" }
29
+
30
+ helpers WebHelpers
31
+
32
+ get '/' do
33
+ index
34
+
35
+ erb :index
36
+ end
37
+
38
+ post '/' do
39
+ if params['disable']
40
+ if job = Zhong.jobs[params['disable']]
41
+ job.disable
42
+ end
43
+ elsif params['enable']
44
+ if job = Zhong.jobs[params['enable']]
45
+ job.enable
46
+ end
47
+ end
48
+
49
+ index
50
+
51
+ erb :index
52
+ end
53
+
54
+ def index
55
+ @jobs = Zhong.jobs.values
56
+ @last_runs = zhong_mget(@jobs, "last_ran")
57
+ @disabled = zhong_mget(@jobs, "disabled")
58
+ @hosts = safe_mget(Zhong.redis.scan_each(match: "zhong:heartbeat:*").to_a).map do |k, v|
59
+ host, pid = k.split("zhong:heartbeat:", 2)[1].split("#", 2)
60
+ {host: host, pid: pid, last_seen: Time.at(v.to_i)}
61
+ end
62
+ end
63
+
64
+ def zhong_mget(jobs, key)
65
+ keys = jobs.map(&:to_s)
66
+ ret = safe_mget(keys.map { |j| "zhong:#{key}:#{j}" })
67
+ Hash[keys.map { |j| [j, ret["zhong:#{key}:#{j}"]] }]
68
+ end
69
+
70
+ def safe_mget(keys)
71
+ if keys.length > 0
72
+ Zhong.redis.mapped_mget(*keys)
73
+ else
74
+ {}
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ if defined?(::ActionDispatch::Request::Session) &&
81
+ !::ActionDispatch::Request::Session.respond_to?(:each)
82
+ # mperham/sidekiq#2460
83
+ # Rack apps can't reuse the Rails session store without
84
+ # this monkeypatch
85
+ class ActionDispatch::Request::Session
86
+ def each(&block)
87
+ hash = self.to_hash
88
+ hash.each(&block)
89
+ end
90
+ end
91
+ end