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.
- checksums.yaml +4 -4
- data/.codeclimate.yml +23 -0
- data/.eslintignore +1 -0
- data/.eslintrc +213 -0
- data/CHANGELOG.md +5 -0
- data/README.md +17 -14
- data/bin/zhong +8 -4
- data/lib/zhong/at.rb +100 -29
- data/lib/zhong/every.rb +24 -3
- data/lib/zhong/job.rb +83 -59
- data/lib/zhong/scheduler.rb +61 -29
- data/lib/zhong/version.rb +1 -1
- data/lib/zhong/web.rb +91 -0
- data/lib/zhong/web_helpers.rb +86 -0
- data/test/{zhong_test.rb → at_test.rb} +34 -4
- data/test/every_test.rb +103 -0
- data/test/job_test.rb +60 -0
- data/test/library_test.rb +7 -0
- data/test/scheduler_test.rb +30 -0
- data/web/assets/javascript/application.js +13 -0
- data/web/assets/javascript/vendor.min.js +21 -0
- data/web/views/index.erb +113 -0
- metadata +22 -6
- data/lib/zhong/util.rb +0 -11
data/lib/zhong/every.rb
CHANGED
@@ -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)
|
data/lib/zhong/job.rb
CHANGED
@@ -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(
|
6
|
-
@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
|
-
@
|
25
|
-
@
|
26
|
-
|
27
|
-
|
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
|
-
|
46
|
-
|
43
|
+
begin
|
44
|
+
redis_lock.lock do
|
45
|
+
locked = true
|
46
|
+
@running = true
|
47
47
|
|
48
|
-
|
48
|
+
refresh_last_ran
|
49
49
|
|
50
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
54
|
+
if disabled?
|
55
|
+
logger.info "not running, disabled: #{self}"
|
56
|
+
break
|
57
|
+
end
|
58
58
|
|
59
|
-
|
59
|
+
logger.info "running: #{self}"
|
60
60
|
|
61
|
-
|
62
|
-
@thread = Thread.new do
|
61
|
+
if @block
|
63
62
|
begin
|
64
63
|
@block.call
|
65
64
|
rescue => boom
|
66
|
-
|
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
|
-
|
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
|
-
@
|
78
|
-
end
|
77
|
+
@running = false
|
79
78
|
|
80
|
-
|
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
|
-
@
|
83
|
+
@running
|
89
84
|
end
|
90
85
|
|
91
86
|
def refresh_last_ran
|
92
|
-
last_ran_val = @redis.get(
|
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(
|
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
|
142
|
-
|
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
|
data/lib/zhong/scheduler.rb
CHANGED
@@ -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
|
-
|
18
|
-
@
|
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
|
-
|
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
|
-
|
59
|
-
if
|
60
|
-
|
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
|
-
|
77
|
+
heartbeat(now)
|
68
78
|
|
69
|
-
|
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
|
-
|
103
|
+
def jobs_to_run(time = redis_time)
|
104
|
+
jobs.select { |_, job| job.run?(time) }
|
105
|
+
end
|
88
106
|
|
89
|
-
def
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
-
|
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
|
99
|
-
|
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
|
-
|
137
|
+
@tz ? now.in_time_zone(@tz) : now
|
106
138
|
end
|
107
139
|
end
|
108
140
|
end
|
data/lib/zhong/version.rb
CHANGED
data/lib/zhong/web.rb
ADDED
@@ -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
|