zhong 0.1.4 → 0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- 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
|