mini_scheduler 0.8.0

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 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: []