zhong 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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