mini_scheduler 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f80dba7428c2a8aa9ab7993aaedc20623997a4710fe13856826361b23d3b2b7
4
+ data.tar.gz: 42eeeafbf3c3764883560f4fc38cfdf59cf9ea7eea30b95111582ff81b98a452
5
+ SHA512:
6
+ metadata.gz: 195412471e37e96582ef2ee9b2ee0d7107f5d2113c918aacd4cf85fb9135d751ae189f8a32beca9bd9719cf0acb54c47a008527585eb48265be239cca54e4bb1
7
+ data.tar.gz: b5ee5701360abf05b16179200dcc56bcbc0bc9203edf2a8cd2038f527dd8dab0513f0563f381ebfbb601d550a0c365ab22ed8f8879434d98caaabf5f6cb1e6fa
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ Gemfile.lock
10
+ .DS_Store
11
+ *.swp
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper --color
data/.rubocop.yml ADDED
@@ -0,0 +1,113 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.4
3
+ DisabledByDefault: true
4
+ Exclude:
5
+ - 'db/schema.rb'
6
+ - 'bundle/**/*'
7
+ - 'vendor/**/*'
8
+ - 'node_modules/**/*'
9
+ - 'public/**/*'
10
+
11
+ # Prefer &&/|| over and/or.
12
+ Style/AndOr:
13
+ Enabled: true
14
+
15
+ # Do not use braces for hash literals when they are the last argument of a
16
+ # method call.
17
+ Style/BracesAroundHashParameters:
18
+ Enabled: true
19
+
20
+ # Align `when` with `case`.
21
+ Layout/CaseIndentation:
22
+ Enabled: true
23
+
24
+ # Align comments with method definitions.
25
+ Layout/CommentIndentation:
26
+ Enabled: true
27
+
28
+ # No extra empty lines.
29
+ Layout/EmptyLines:
30
+ Enabled: true
31
+
32
+ # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }.
33
+ Style/HashSyntax:
34
+ Enabled: true
35
+
36
+ # Two spaces, no tabs (for indentation).
37
+ Layout/IndentationWidth:
38
+ Enabled: true
39
+
40
+ Layout/SpaceAfterColon:
41
+ Enabled: true
42
+
43
+ Layout/SpaceAfterComma:
44
+ Enabled: true
45
+
46
+ Layout/SpaceAroundEqualsInParameterDefault:
47
+ Enabled: true
48
+
49
+ Layout/SpaceAroundKeyword:
50
+ Enabled: true
51
+
52
+ Layout/SpaceAroundOperators:
53
+ Enabled: true
54
+
55
+ Layout/SpaceBeforeFirstArg:
56
+ Enabled: true
57
+
58
+ # Defining a method with parameters needs parentheses.
59
+ Style/MethodDefParentheses:
60
+ Enabled: true
61
+
62
+ # Use `foo {}` not `foo{}`.
63
+ Layout/SpaceBeforeBlockBraces:
64
+ Enabled: true
65
+
66
+ # Use `foo { bar }` not `foo {bar}`.
67
+ Layout/SpaceInsideBlockBraces:
68
+ Enabled: true
69
+
70
+ # Use `{ a: 1 }` not `{a:1}`.
71
+ Layout/SpaceInsideHashLiteralBraces:
72
+ Enabled: true
73
+
74
+ Layout/SpaceInsideParens:
75
+ Enabled: true
76
+
77
+ # Detect hard tabs, no hard tabs.
78
+ Layout/Tab:
79
+ Enabled: true
80
+
81
+ # Blank lines should not have any spaces.
82
+ Layout/TrailingBlankLines:
83
+ Enabled: true
84
+
85
+ # No trailing whitespace.
86
+ Layout/TrailingWhitespace:
87
+ Enabled: true
88
+
89
+ Lint/Debugger:
90
+ Enabled: true
91
+
92
+ Lint/BlockAlignment:
93
+ Enabled: true
94
+
95
+ # Align `end` with the matching keyword or starting expression except for
96
+ # assignments, where it should be aligned with the LHS.
97
+ Lint/EndAlignment:
98
+ Enabled: true
99
+ EnforcedStyleAlignWith: variable
100
+
101
+ # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg.
102
+ Lint/RequireParentheses:
103
+ Enabled: true
104
+
105
+ Layout/MultilineMethodCallIndentation:
106
+ Enabled: true
107
+ EnforcedStyle: indented
108
+
109
+ Layout/AlignHash:
110
+ Enabled: true
111
+
112
+ Bundler/OrderedGems:
113
+ Enabled: false
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ git_source(:github) { 'https://github.com/discourse/mini_scheduler' }
4
+
5
+ gemspec
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # mini_scheduler
2
+
3
+ MiniScheduler adds recurring jobs to [Sidekiq](https://sidekiq.org/).
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'mini_scheduler'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install mini_scheduler
20
+
21
+ In a Rails application, create files needed in your application to configure mini_scheduler:
22
+
23
+ bin/rails g mini_scheduler:install
24
+ rake db:migrate
25
+
26
+ An initializer is created named `config/initializers/mini_scheduler.rb` which lists all the configuration options.
27
+
28
+ ## Usage
29
+
30
+ Create jobs with a recurring schedule like this:
31
+
32
+ ```ruby
33
+ class MyHourlyJob
34
+ include Sidekiq::Worker
35
+ extend MiniScheduler::Schedule
36
+
37
+ every 1.hour
38
+
39
+ def execute(args)
40
+ # some tasks
41
+ end
42
+ end
43
+ ```
44
+
45
+ Options for schedules:
46
+
47
+ * **every** followed by a duration in seconds, like "every 1.hour".
48
+ * **daily at:** followed by a duration since midnight, like "daily at: 12.hours", to run only once per day at a specific time.
49
+
50
+ To view the scheduled jobs, their history, and the schedule, go to sidekiq's web UI and look for the "Scheduler" tab at the top.
@@ -0,0 +1,10 @@
1
+ module MiniScheduler
2
+ class Stat < ActiveRecord::Base
3
+
4
+ self.table_name = 'scheduler_stats'
5
+
6
+ def self.purge_old
7
+ where('started_at < ?', 1.months.ago).delete_all
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,25 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+
4
+ module MiniScheduler
5
+ module Generators
6
+ class InstallGenerator < ::Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+ source_root File.expand_path('../templates', __FILE__)
9
+ desc "Generate files for MiniScheduler"
10
+
11
+ def self.next_migration_number(path)
12
+ next_migration_number = current_migration_number(path) + 1
13
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
14
+ end
15
+
16
+ def copy_migrations
17
+ migration_template("create_mini_scheduler_stats.rb", "db/migrate/create_mini_scheduler_stats.rb")
18
+ end
19
+
20
+ def copy_initializer_file
21
+ copy_file "mini_scheduler_initializer.rb", "config/initializers/mini_scheduler.rb"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ class CreateMiniSchedulerStats < ActiveRecord::Migration[4.2]
2
+ def change
3
+ create_table :scheduler_stats do |t|
4
+ t.string :name, null: false
5
+ t.string :hostname, null: false
6
+ t.integer :pid, null: false
7
+ t.integer :duration_ms
8
+ t.integer :live_slots_start
9
+ t.integer :live_slots_finish
10
+ t.datetime :started_at, null: false
11
+ t.boolean :success
12
+ t.text :error
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,31 @@
1
+ MiniScheduler.configure do |config|
2
+ # An instance of Redis. See https://github.com/redis/redis-rb
3
+
4
+ # config.redis = $redis
5
+
6
+ # Define a custom exception handler when an exception is raised
7
+ # by a scheduled job. By default, SidekiqExceptionHandler is used.
8
+
9
+ # config.job_exception_handler do |ex, context|
10
+ # ...
11
+ # end
12
+
13
+ # Add code to be called after a scheduled job runs. An argument
14
+ # with stats about the execution is passed, including these fields:
15
+ # name, hostname, pid, started_at, duration_ms, live_slots_start,
16
+ # live_slots_finish, success, error
17
+
18
+ # config.job_ran do |stats|
19
+ # ...
20
+ # end
21
+ end
22
+
23
+ if Sidekiq.server? && defined?(Rails)
24
+ Rails.application.config.after_initialize do
25
+ scheduler_hostname = ENV["UNICORN_SCHEDULER_HOSTNAME"]
26
+
27
+ if !scheduler_hostname || scheduler_hostname.split(',').include?(`hostname`.strip)
28
+ MiniScheduler.start
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,61 @@
1
+ module MiniScheduler
2
+ class DistributedMutex
3
+
4
+ @default_redis = nil
5
+
6
+ def self.redis=(redis)
7
+ @default_redis = redis
8
+ end
9
+
10
+ def self.synchronize(key, redis = nil, &blk)
11
+ self.new(key, redis || @default_redis).synchronize(&blk)
12
+ end
13
+
14
+ def initialize(key, redis)
15
+ raise ArgumentError.new('redis argument is nil') if redis.nil?
16
+ @key = key
17
+ @redis = redis
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ # NOTE wrapped in mutex to maintain its semantics
22
+ def synchronize
23
+ @mutex.lock
24
+ while !try_to_get_lock
25
+ sleep 0.001
26
+ end
27
+
28
+ yield
29
+
30
+ ensure
31
+ @redis.del @key
32
+ @mutex.unlock
33
+ end
34
+
35
+ private
36
+
37
+ def try_to_get_lock
38
+ got_lock = false
39
+ if @redis.setnx @key, Time.now.to_i + 60
40
+ @redis.expire @key, 60
41
+ got_lock = true
42
+ else
43
+ begin
44
+ @redis.watch @key
45
+ time = @redis.get @key
46
+ if time && time.to_i < Time.now.to_i
47
+ got_lock = @redis.multi do
48
+ @redis.set @key, Time.now.to_i + 60
49
+ end
50
+ end
51
+ ensure
52
+ @redis.unwatch
53
+ end
54
+ end
55
+
56
+ got_lock
57
+ end
58
+
59
+ end
60
+
61
+ end
@@ -0,0 +1,7 @@
1
+ if defined?(::Rails)
2
+ module MiniScheduler
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace MiniScheduler
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,348 @@
1
+ module MiniScheduler
2
+ class Manager
3
+ attr_accessor :random_ratio, :redis, :enable_stats
4
+
5
+ class Runner
6
+ def initialize(manager)
7
+ @stopped = false
8
+ @mutex = Mutex.new
9
+ @queue = Queue.new
10
+ @manager = manager
11
+ @reschedule_orphans_thread = Thread.new do
12
+ while !@stopped
13
+ sleep 1.minute
14
+ @mutex.synchronize do
15
+ reschedule_orphans
16
+ end
17
+ end
18
+ end
19
+ @keep_alive_thread = Thread.new do
20
+ while !@stopped
21
+ @mutex.synchronize do
22
+ keep_alive
23
+ end
24
+ sleep (@manager.keep_alive_duration / 2)
25
+ end
26
+ end
27
+ @thread = Thread.new do
28
+ while !@stopped
29
+ process_queue
30
+ end
31
+ end
32
+ end
33
+
34
+ def keep_alive
35
+ @manager.keep_alive
36
+ rescue => ex
37
+ MiniScheduler.handle_job_exception(ex, message: "Scheduling manager keep-alive")
38
+ end
39
+
40
+ def reschedule_orphans
41
+ @manager.reschedule_orphans!
42
+ rescue => ex
43
+ MiniScheduler.handle_job_exception(ex, message: "Scheduling manager orphan rescheduler")
44
+ end
45
+
46
+ def hostname
47
+ @hostname ||= begin
48
+ `hostname`
49
+ rescue
50
+ "unknown"
51
+ end
52
+ end
53
+
54
+ def process_queue
55
+
56
+ klass = @queue.deq
57
+ return unless klass
58
+
59
+ # hack alert, I need to both deq and set @running atomically.
60
+ @running = true
61
+ failed = false
62
+ start = Time.now.to_f
63
+ info = @mutex.synchronize { @manager.schedule_info(klass) }
64
+ stat = nil
65
+ error = nil
66
+
67
+ begin
68
+ info.prev_result = "RUNNING"
69
+ @mutex.synchronize { info.write! }
70
+
71
+ if @manager.enable_stats
72
+ stat = MiniScheduler::Stat.create!(
73
+ name: klass.to_s,
74
+ hostname: hostname,
75
+ pid: Process.pid,
76
+ started_at: Time.now,
77
+ live_slots_start: GC.stat[:heap_live_slots]
78
+ )
79
+ end
80
+
81
+ klass.new.perform
82
+ rescue => e
83
+ MiniScheduler.handle_job_exception(e, message: "Running a scheduled job", job: klass)
84
+
85
+ error = "#{e.class}: #{e.message} #{e.backtrace.join("\n")}"
86
+ failed = true
87
+ end
88
+ duration = ((Time.now.to_f - start) * 1000).to_i
89
+ info.prev_duration = duration
90
+ info.prev_result = failed ? "FAILED" : "OK"
91
+ info.current_owner = nil
92
+ if stat
93
+ stat.update!(
94
+ duration_ms: duration,
95
+ live_slots_finish: GC.stat[:heap_live_slots],
96
+ success: !failed,
97
+ error: error
98
+ )
99
+ MiniScheduler.job_ran&.call(stat)
100
+ end
101
+ attempts(3) do
102
+ @mutex.synchronize { info.write! }
103
+ end
104
+ rescue => ex
105
+ MiniScheduler.handle_job_exception(ex, message: "Processing scheduled job queue")
106
+ ensure
107
+ @running = false
108
+ if defined?(ActiveRecord::Base)
109
+ ActiveRecord::Base.connection_handler.clear_active_connections!
110
+ end
111
+ end
112
+
113
+ def stop!
114
+ return if @stopped
115
+
116
+ @mutex.synchronize do
117
+ @stopped = true
118
+
119
+ @keep_alive_thread.kill
120
+ @reschedule_orphans_thread.kill
121
+
122
+ @keep_alive_thread.join
123
+ @reschedule_orphans_thread.join
124
+
125
+ enq(nil)
126
+
127
+ kill_thread = Thread.new do
128
+ sleep 0.5
129
+ @thread.kill
130
+ end
131
+
132
+ @thread.join
133
+ kill_thread.kill
134
+ kill_thread.join
135
+ end
136
+ end
137
+
138
+ def enq(klass)
139
+ @queue << klass
140
+ end
141
+
142
+ def wait_till_done
143
+ while !@queue.empty? && !(@queue.num_waiting > 0)
144
+ sleep 0.001
145
+ end
146
+ # this is a hack, but is only used for test anyway
147
+ sleep 0.001
148
+ while @running
149
+ sleep 0.001
150
+ end
151
+ end
152
+
153
+ def attempts(n)
154
+ n.times {
155
+ begin
156
+ yield; break
157
+ rescue
158
+ sleep Random.rand
159
+ end
160
+ }
161
+ end
162
+
163
+ end
164
+
165
+ def self.without_runner
166
+ self.new(skip_runner: true)
167
+ end
168
+
169
+ def initialize(options = nil)
170
+ @redis = MiniScheduler.redis
171
+ @random_ratio = 0.1
172
+ unless options && options[:skip_runner]
173
+ @runner = Runner.new(self)
174
+ self.class.current = self
175
+ end
176
+
177
+ @hostname = options && options[:hostname]
178
+ @manager_id = SecureRandom.hex
179
+
180
+ if options && options.key?(:enable_stats)
181
+ @enable_stats = options[:enable_stats]
182
+ else
183
+ @enable_stats = true # doesn't work !!defined?(MiniScheduler::Stat)
184
+ end
185
+ end
186
+
187
+ def self.current
188
+ @current
189
+ end
190
+
191
+ def self.current=(manager)
192
+ @current = manager
193
+ end
194
+
195
+ def hostname
196
+ @hostname ||= `hostname`.strip
197
+ end
198
+
199
+ def schedule_info(klass)
200
+ MiniScheduler::ScheduleInfo.new(klass, self)
201
+ end
202
+
203
+ def next_run(klass)
204
+ schedule_info(klass).next_run
205
+ end
206
+
207
+ def ensure_schedule!(klass)
208
+ lock do
209
+ schedule_info(klass).schedule!
210
+ end
211
+ end
212
+
213
+ def remove(klass)
214
+ lock do
215
+ schedule_info(klass).del!
216
+ end
217
+ end
218
+
219
+ def reschedule_orphans!
220
+ lock do
221
+ reschedule_orphans_on!
222
+ reschedule_orphans_on!(hostname)
223
+ end
224
+ end
225
+
226
+ def reschedule_orphans_on!(hostname = nil)
227
+ redis.zrange(Manager.queue_key(hostname), 0, -1).each do |key|
228
+ klass = get_klass(key)
229
+ next unless klass
230
+ info = schedule_info(klass)
231
+
232
+ if ['QUEUED', 'RUNNING'].include?(info.prev_result) &&
233
+ (info.current_owner.blank? || !redis.get(info.current_owner))
234
+ info.prev_result = 'ORPHAN'
235
+ info.next_run = Time.now.to_i
236
+ info.write!
237
+ end
238
+ end
239
+ end
240
+
241
+ def get_klass(name)
242
+ name.constantize
243
+ rescue NameError
244
+ nil
245
+ end
246
+
247
+ def tick
248
+ lock do
249
+ schedule_next_job
250
+ schedule_next_job(hostname)
251
+ end
252
+ end
253
+
254
+ def schedule_next_job(hostname = nil)
255
+ (key, due), _ = redis.zrange Manager.queue_key(hostname), 0, 0, withscores: true
256
+ return unless key
257
+
258
+ if due.to_i <= Time.now.to_i
259
+ klass = get_klass(key)
260
+ unless klass
261
+ # corrupt key, nuke it (renamed job or something)
262
+ redis.zrem Manager.queue_key(hostname), key
263
+ return
264
+ end
265
+ info = schedule_info(klass)
266
+ info.prev_run = Time.now.to_i
267
+ info.prev_result = "QUEUED"
268
+ info.prev_duration = -1
269
+ info.next_run = nil
270
+ info.current_owner = identity_key
271
+ info.schedule!
272
+ @runner.enq(klass)
273
+ end
274
+ end
275
+
276
+ def blocking_tick
277
+ tick
278
+ @runner.wait_till_done
279
+ end
280
+
281
+ def stop!
282
+ @runner.stop!
283
+ self.class.current = nil
284
+ end
285
+
286
+ def keep_alive_duration
287
+ 60
288
+ end
289
+
290
+ def keep_alive
291
+ redis.setex identity_key, keep_alive_duration, ""
292
+ end
293
+
294
+ def lock
295
+ MiniScheduler::DistributedMutex.synchronize(Manager.lock_key, MiniScheduler.redis) do
296
+ yield
297
+ end
298
+ end
299
+
300
+ def self.discover_schedules
301
+ # hack for developemnt reloader is crazytown
302
+ # multiple classes with same name can be in
303
+ # object space
304
+ unique = Set.new
305
+ schedules = []
306
+ ObjectSpace.each_object(MiniScheduler::Schedule) do |schedule|
307
+ if schedule.scheduled?
308
+ next if unique.include?(schedule.to_s)
309
+ schedules << schedule
310
+ unique << schedule.to_s
311
+ end
312
+ end
313
+ schedules
314
+ end
315
+
316
+ @mutex = Mutex.new
317
+ def self.seq
318
+ @mutex.synchronize do
319
+ @i ||= 0
320
+ @i += 1
321
+ end
322
+ end
323
+
324
+ def identity_key
325
+ @identity_key ||= "_scheduler_#{hostname}:#{Process.pid}:#{self.class.seq}:#{SecureRandom.hex}"
326
+ end
327
+
328
+ def self.lock_key
329
+ "_scheduler_lock_"
330
+ end
331
+
332
+ def self.queue_key(hostname = nil)
333
+ if hostname
334
+ "_scheduler_queue_#{hostname}_"
335
+ else
336
+ "_scheduler_queue_"
337
+ end
338
+ end
339
+
340
+ def self.schedule_key(klass, hostname = nil)
341
+ if hostname
342
+ "_scheduler_#{klass}_#{hostname}"
343
+ else
344
+ "_scheduler_#{klass}"
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,37 @@
1
+ module MiniScheduler::Schedule
2
+
3
+ def daily(options = nil)
4
+ if options
5
+ @daily = options
6
+ end
7
+ @daily
8
+ end
9
+
10
+ def every(duration = nil)
11
+ if duration
12
+ @every = duration
13
+ if manager = MiniScheduler::Manager.current
14
+ manager.ensure_schedule!(self)
15
+ end
16
+ end
17
+ @every
18
+ end
19
+
20
+ # schedule job independently on each host (looking at hostname)
21
+ def per_host
22
+ @per_host = true
23
+ end
24
+
25
+ def is_per_host
26
+ @per_host
27
+ end
28
+
29
+ def schedule_info
30
+ manager = MiniScheduler::Manager.without_runner
31
+ manager.schedule_info self
32
+ end
33
+
34
+ def scheduled?
35
+ !!@every || !!@daily
36
+ end
37
+ end
@@ -0,0 +1,138 @@
1
+ module MiniScheduler
2
+ class ScheduleInfo
3
+ attr_accessor :next_run,
4
+ :prev_run,
5
+ :prev_duration,
6
+ :prev_result,
7
+ :current_owner
8
+
9
+ def initialize(klass, manager)
10
+ @klass = klass
11
+ @manager = manager
12
+
13
+ data = nil
14
+
15
+ if data = @manager.redis.get(key)
16
+ data = JSON.parse(data)
17
+ end
18
+
19
+ if data
20
+ @next_run = data["next_run"]
21
+ @prev_run = data["prev_run"]
22
+ @prev_result = data["prev_result"]
23
+ @prev_duration = data["prev_duration"]
24
+ @current_owner = data["current_owner"]
25
+ end
26
+ rescue
27
+ # corrupt redis
28
+ @next_run = @prev_run = @prev_result = @prev_duration = @current_owner = nil
29
+ end
30
+
31
+ # this means the schedule is going to fire, it is setup correctly.
32
+ # invalid schedules are fixed by running "schedule!"
33
+ # this happens automatically after if fire by the manager.
34
+ def valid?
35
+ return false unless @next_run
36
+ (!@prev_run && @next_run < Time.now.to_i + 5.minutes) || valid_every? || valid_daily?
37
+ end
38
+
39
+ def valid_every?
40
+ return false unless @klass.every
41
+ !!@prev_run &&
42
+ @prev_run <= Time.now.to_i &&
43
+ @next_run < @prev_run + @klass.every * (1 + @manager.random_ratio)
44
+ end
45
+
46
+ def valid_daily?
47
+ return false unless @klass.daily
48
+ return true if !@prev_run && @next_run && @next_run <= (Time.now + 1.day).to_i
49
+ !!@prev_run &&
50
+ @prev_run <= Time.now.to_i &&
51
+ @next_run < @prev_run + 1.day
52
+ end
53
+
54
+ def schedule_every!
55
+ if !valid? && @prev_run
56
+ mixup = @klass.every * @manager.random_ratio
57
+ mixup = (mixup * Random.rand - mixup / 2).to_i
58
+ @next_run = @prev_run + mixup + @klass.every
59
+ end
60
+
61
+ if !valid?
62
+ @next_run = Time.now.to_i + 5.minutes * Random.rand
63
+ end
64
+ end
65
+
66
+ def schedule_daily!
67
+ return if valid?
68
+
69
+ at = @klass.daily[:at] || 0
70
+ today_begin = Time.now.midnight.to_i
71
+ today_offset = DateTime.now.seconds_since_midnight
72
+
73
+ # If it's later today
74
+ if at > today_offset
75
+ @next_run = today_begin + at
76
+ else
77
+ # Otherwise do it tomorrow
78
+ @next_run = today_begin + 1.day + at
79
+ end
80
+ end
81
+
82
+ def schedule!
83
+ if @klass.every
84
+ schedule_every!
85
+ elsif @klass.daily
86
+ schedule_daily!
87
+ end
88
+
89
+ write!
90
+ end
91
+
92
+ def write!
93
+
94
+ clear!
95
+ redis.set key, {
96
+ next_run: @next_run,
97
+ prev_run: @prev_run,
98
+ prev_duration: @prev_duration,
99
+ prev_result: @prev_result,
100
+ current_owner: @current_owner
101
+ }.to_json
102
+
103
+ redis.zadd queue_key, @next_run, @klass if @next_run
104
+ end
105
+
106
+ def del!
107
+ clear!
108
+ @next_run = @prev_run = @prev_result = @prev_duration = @current_owner = nil
109
+ end
110
+
111
+ def key
112
+ if @klass.is_per_host
113
+ Manager.schedule_key(@klass, @manager.hostname)
114
+ else
115
+ Manager.schedule_key(@klass)
116
+ end
117
+ end
118
+
119
+ def queue_key
120
+ if @klass.is_per_host
121
+ Manager.queue_key(@manager.hostname)
122
+ else
123
+ Manager.queue_key
124
+ end
125
+ end
126
+
127
+ def redis
128
+ @manager.redis
129
+ end
130
+
131
+ private
132
+ def clear!
133
+ redis.del key
134
+ redis.zrem queue_key, @klass
135
+ end
136
+
137
+ end
138
+ end
@@ -0,0 +1,3 @@
1
+ module MiniScheduler
2
+ VERSION = "0.8.0"
3
+ end
@@ -0,0 +1,47 @@
1
+ <header class="row">
2
+ <div class="col-sm-12">
3
+ <h3>Scheduler History</h3>
4
+ </div>
5
+ </header>
6
+
7
+ <div class="container">
8
+ <div class="row">
9
+ <div class="col-md-9">
10
+ <% if @scheduler_stats.length > 0 %>
11
+
12
+ <table class="table table-striped table-bordered table-white" style="width: 100%; margin: 0; table-layout:fixed;">
13
+ <thead>
14
+ <th style="width: 30%">Job Name</th>
15
+ <th style="width: 15%">Hostname:Pid</th>
16
+ <th style="width: 15%">Live Slots delta</th>
17
+ <th style="width: 15%">Started At</th>
18
+ <th style="width: 15%">Duration</th>
19
+ <th style="width: 15%"></th>
20
+ </thead>
21
+ <tbody>
22
+ <% @scheduler_stats.each do |stat| %>
23
+ <tr>
24
+ <td><%= stat.name %></td>
25
+ <td><%= stat.hostname %>:<%= stat.pid %></td>
26
+ <td>
27
+ <% if stat.live_slots_start && stat.live_slots_finish %>
28
+ <%= stat.live_slots_finish - stat.live_slots_start %>
29
+ <% end %>
30
+ </td>
31
+ <td><%= sane_time stat.started_at %></td>
32
+ <td><%= sane_duration stat.duration_ms %></td>
33
+ <td>
34
+ <% if stat.success.nil? %>
35
+ RUNNING
36
+ <% elsif !stat.success %>
37
+ FAILED
38
+ <% end %>
39
+ </td>
40
+ </tr>
41
+ <% end %>
42
+ </tbody>
43
+ </table>
44
+ <% end %>
45
+ </div>
46
+ </div>
47
+ </div>
@@ -0,0 +1,73 @@
1
+ <header class="row">
2
+ <% if Sidekiq.respond_to?(:paused?) && Sidekiq.paused? %>
3
+ <div class="col-sm-12">
4
+ <div class="alert alert-danger text-center">
5
+ <h2>SIDEKIQ IS PAUSED!</h2>
6
+ </div>
7
+ </div>
8
+ <% end %>
9
+ <div class="col-sm-12">
10
+ <h3>Recurring Jobs <a style='font-size:50%; margin-left: 30px' href='scheduler/history'>history</a></h3>
11
+ </div>
12
+ </header>
13
+
14
+ <div class="container">
15
+ <div class="row">
16
+
17
+ <div class="col-md-9">
18
+ <% if @schedules.length > 0 %>
19
+ <table class="table table-striped table-bordered table-white" style="width: 100%; margin: 0; table-layout:fixed;">
20
+ <thead>
21
+ <th style="width: 30%">Worker</th>
22
+ <th style="width: 15%">Last Run</th>
23
+ <th style="width: 15%">Last Result</th>
24
+ <th style="width: 15%">Last Duration</th>
25
+ <th style="width: 15%">Last Owner</th>
26
+ <th style="width: 15%">Next Run Due</th>
27
+ <th style="width: 10%">Actions</th>
28
+ </thead>
29
+ <% @schedules.each do |schedule| %>
30
+ <% @info = schedule.schedule_info %>
31
+ <tr>
32
+ <td>
33
+ <%= schedule %>
34
+ <td>
35
+ <% prev = @info.prev_run %>
36
+ <% if prev.nil? %>
37
+ Never
38
+ <% else %>
39
+ <%= relative_time(Time.at(prev)) %>
40
+ <% end %>
41
+ </td>
42
+ <td>
43
+ <%= @info.prev_result %>
44
+ </td>
45
+ <td>
46
+ <%= sane_duration @info.prev_duration %>
47
+ </td>
48
+ <td>
49
+ <%= @info.current_owner %>
50
+ </td>
51
+ <td>
52
+ <% next_run = @info.next_run %>
53
+ <% if next_run.nil? %>
54
+ Not Scheduled Yet
55
+ <% else %>
56
+ <%= relative_time(Time.at(next_run)) %>
57
+ <% end %>
58
+ </td>
59
+ <td>
60
+ <form action="<%= "#{root_path}scheduler/#{schedule}/trigger" %>" method="post">
61
+ <%= csrf_tag if respond_to?(:csrf_tag) %>
62
+ <input class="btn btn-danger btn-small" type="submit" name="trigger" value="Trigger" data-confirm="Are you sure you want to trigger this job?" />
63
+ </form>
64
+ </td>
65
+ </tr>
66
+ <% end %>
67
+ </table>
68
+ <% else %>
69
+ <div class="alert alert-success">No recurring jobs found.</div>
70
+ <% end %>
71
+ </div>
72
+ </div>
73
+ </div>
@@ -0,0 +1,60 @@
1
+ # Based off sidetiq https://github.com/tobiassvn/sidetiq/blob/master/lib/sidetiq/web.rb
2
+ module MiniScheduler
3
+ module Web
4
+ VIEWS = File.expand_path('views', File.dirname(__FILE__)) unless defined? VIEWS
5
+
6
+ def self.registered(app)
7
+
8
+ app.helpers do
9
+ def sane_time(time)
10
+ return unless time
11
+ time
12
+ end
13
+
14
+ def sane_duration(duration)
15
+ return unless duration
16
+ if duration < 1000
17
+ "#{duration}ms"
18
+ elsif duration < 60 * 1000
19
+ "#{'%.2f' % (duration / 1000.0)} secs"
20
+ end
21
+ end
22
+ end
23
+
24
+ app.get "/scheduler" do
25
+ @schedules = Manager.discover_schedules.sort do |a, b|
26
+ a_next = a.schedule_info.next_run
27
+ b_next = b.schedule_info.next_run
28
+ if a_next && b_next
29
+ a_next <=> b_next
30
+ elsif a_next
31
+ -1
32
+ else
33
+ 1
34
+ end
35
+ end
36
+ erb File.read(File.join(VIEWS, 'scheduler.erb')), locals: { view_path: VIEWS }
37
+ end
38
+
39
+ app.get "/scheduler/history" do
40
+ @scheduler_stats = Stat.order('started_at desc').limit(200)
41
+ erb File.read(File.join(VIEWS, 'history.erb')), locals: { view_path: VIEWS }
42
+ end
43
+
44
+ app.post "/scheduler/:name/trigger" do
45
+ halt 404 unless (name = params[:name])
46
+
47
+ klass = name.constantize
48
+ info = klass.schedule_info
49
+ info.next_run = Time.now.to_i
50
+ info.write!
51
+
52
+ redirect "#{root_path}scheduler"
53
+ end
54
+
55
+ end
56
+ end
57
+ end
58
+
59
+ Sidekiq::Web.register(MiniScheduler::Web)
60
+ Sidekiq::Web.tabs["Scheduler"] = "scheduler"
@@ -0,0 +1,63 @@
1
+ require "mini_scheduler/engine"
2
+ require 'mini_scheduler/schedule'
3
+ require 'mini_scheduler/schedule_info'
4
+ require 'mini_scheduler/manager'
5
+ require 'mini_scheduler/distributed_mutex'
6
+
7
+ require 'sidekiq/exception_handler'
8
+
9
+ module MiniScheduler
10
+
11
+ def self.configure
12
+ yield self
13
+ end
14
+
15
+ class SidekiqExceptionHandler
16
+ extend Sidekiq::ExceptionHandler
17
+ end
18
+
19
+ def self.job_exception_handler(&blk)
20
+ @job_exception_handler = blk if blk
21
+ @job_exception_handler
22
+ end
23
+
24
+ def self.handle_job_exception(ex, context = {})
25
+ if job_exception_handler
26
+ job_exception_handler.call(ex, context)
27
+ else
28
+ SidekiqExceptionHandler.handle_exception(ex, context)
29
+ end
30
+ end
31
+
32
+ def self.redis=(r)
33
+ @redis = r
34
+ end
35
+
36
+ def self.redis
37
+ @redis
38
+ end
39
+
40
+ def self.job_ran(&blk)
41
+ @job_ran = blk if blk
42
+ @job_ran
43
+ end
44
+
45
+ def self.start
46
+ manager = Manager.new
47
+ Manager.discover_schedules.each do |schedule|
48
+ manager.ensure_schedule!(schedule)
49
+ end
50
+ Thread.new do
51
+ while true
52
+ begin
53
+ manager.tick
54
+ rescue => e
55
+ # the show must go on
56
+ handle_job_exception(e, message: "While ticking scheduling manager")
57
+ end
58
+ sleep 1
59
+ end
60
+ end
61
+ end
62
+
63
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("../lib", __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "mini_scheduler/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "mini_scheduler"
9
+ spec.version = MiniScheduler::VERSION
10
+ spec.authors = ["Sam Saffron", "Neil Lalonde"]
11
+ spec.email = ["neil.lalonde@discourse.org"]
12
+
13
+ spec.summary = %q{Adds recurring jobs for Sidekiq}
14
+ spec.description = %q{Adds recurring jobs for Sidekiq}
15
+ spec.homepage = "https://github.com/discourse/mini_scheduler"
16
+ spec.license = "MIT"
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
+ end
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.16"
26
+ spec.add_development_dependency "rake", "~> 10.0"
27
+ spec.add_development_dependency "minitest", "~> 5.0"
28
+ spec.add_development_dependency "pg", "~> 1.0.0"
29
+ spec.add_development_dependency "guard", "~> 2.14"
30
+ spec.add_development_dependency "guard-minitest", "~> 2.4"
31
+ spec.add_development_dependency "activesupport", "~> 5.2"
32
+ spec.add_development_dependency "rspec"
33
+ spec.add_development_dependency "mocha"
34
+ spec.add_development_dependency "mock_redis"
35
+ spec.add_development_dependency "sidekiq"
36
+ end
metadata ADDED
@@ -0,0 +1,219 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mini_scheduler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.8.0
5
+ platform: ruby
6
+ authors:
7
+ - Sam Saffron
8
+ - Neil Lalonde
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2018-07-11 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '1.16'
21
+ type: :development
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '1.16'
28
+ - !ruby/object:Gem::Dependency
29
+ name: rake
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '10.0'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '10.0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: minitest
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '5.0'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '5.0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: pg
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: 1.0.0
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: 1.0.0
70
+ - !ruby/object:Gem::Dependency
71
+ name: guard
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '2.14'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: '2.14'
84
+ - !ruby/object:Gem::Dependency
85
+ name: guard-minitest
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '2.4'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '2.4'
98
+ - !ruby/object:Gem::Dependency
99
+ name: activesupport
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - "~>"
103
+ - !ruby/object:Gem::Version
104
+ version: '5.2'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - "~>"
110
+ - !ruby/object:Gem::Version
111
+ version: '5.2'
112
+ - !ruby/object:Gem::Dependency
113
+ name: rspec
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: mocha
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ type: :development
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ - !ruby/object:Gem::Dependency
141
+ name: mock_redis
142
+ requirement: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ type: :development
148
+ prerelease: false
149
+ version_requirements: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
154
+ - !ruby/object:Gem::Dependency
155
+ name: sidekiq
156
+ requirement: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '0'
161
+ type: :development
162
+ prerelease: false
163
+ version_requirements: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: '0'
168
+ description: Adds recurring jobs for Sidekiq
169
+ email:
170
+ - neil.lalonde@discourse.org
171
+ executables: []
172
+ extensions: []
173
+ extra_rdoc_files: []
174
+ files:
175
+ - ".gitignore"
176
+ - ".rspec"
177
+ - ".rubocop.yml"
178
+ - Gemfile
179
+ - README.md
180
+ - app/models/mini_scheduler/stat.rb
181
+ - lib/generators/mini_scheduler/install/install_generator.rb
182
+ - lib/generators/mini_scheduler/install/templates/create_mini_scheduler_stats.rb
183
+ - lib/generators/mini_scheduler/install/templates/mini_scheduler_initializer.rb
184
+ - lib/mini_scheduler.rb
185
+ - lib/mini_scheduler/distributed_mutex.rb
186
+ - lib/mini_scheduler/engine.rb
187
+ - lib/mini_scheduler/manager.rb
188
+ - lib/mini_scheduler/schedule.rb
189
+ - lib/mini_scheduler/schedule_info.rb
190
+ - lib/mini_scheduler/version.rb
191
+ - lib/mini_scheduler/views/history.erb
192
+ - lib/mini_scheduler/views/scheduler.erb
193
+ - lib/mini_scheduler/web.rb
194
+ - mini_scheduler.gemspec
195
+ homepage: https://github.com/discourse/mini_scheduler
196
+ licenses:
197
+ - MIT
198
+ metadata: {}
199
+ post_install_message:
200
+ rdoc_options: []
201
+ require_paths:
202
+ - lib
203
+ required_ruby_version: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - ">="
206
+ - !ruby/object:Gem::Version
207
+ version: '0'
208
+ required_rubygems_version: !ruby/object:Gem::Requirement
209
+ requirements:
210
+ - - ">="
211
+ - !ruby/object:Gem::Version
212
+ version: '0'
213
+ requirements: []
214
+ rubyforge_project:
215
+ rubygems_version: 2.7.7
216
+ signing_key:
217
+ specification_version: 4
218
+ summary: Adds recurring jobs for Sidekiq
219
+ test_files: []