rsched 0.1.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.
Files changed (4) hide show
  1. data/README.rdoc +93 -0
  2. data/bin/rsched +6 -0
  3. data/lib/rsched/command/rsched.rb +535 -0
  4. metadata +100 -0
data/README.rdoc ADDED
@@ -0,0 +1,93 @@
1
+ = rsched
2
+
3
+ A Generic Reliable Scheduler. It's like cron, but rsched supports redundancy using multiple servers.
4
+
5
+
6
+ == Architecture
7
+
8
+ 1. rsched virtually locks a record on a RDBMS.
9
+ 2. if the schedule is not finished and not locked by other node, run it.
10
+ 3. if it succeeded, mark it finished.
11
+ 4. if it failed, unlock it and expect to be retried.
12
+
13
+
14
+ == Install
15
+
16
+ $ gem install rsched
17
+ $ gem install dbd-mysql # to use MySQL as a lock database
18
+ $ gem install dbd-sqlite3 # to use SQLite3 as a lock database
19
+
20
+
21
+ == Schedule
22
+
23
+ A schedule consists of ident, time and action. Ident describes unique identifier of the schedule that. Time describes when it should be scheduled. Action is an description of the schedule.
24
+
25
+ Format of the time is same as cron. See `man 5 cron` for details.
26
+
27
+ _Example:_
28
+
29
+ # Run every minutes
30
+ $ rsched -a 'mysched * * * * * my descriptoin of this action' ...
31
+
32
+ # Run every day at 00:00
33
+ $ rsched -a 'mywork 0 0 * * * aaaa uuu ee' ...
34
+
35
+
36
+ == Usage
37
+
38
+ Usage: rsched [options] [-- <ARGV-for-exec-or-run>]
39
+ --configure PATH.yaml Write configuration file
40
+ --exec COMMAND Execute command
41
+ --run SCRIPT.rb Run method named 'run' defined in the script
42
+ -a, --add EXPR Add an execution schedule
43
+ -t, --timeout SEC Retry timeout (default: 600)
44
+ -r, --resume SEC Limit time to resume tasks (default: 3600)
45
+ -E, --delete SEC Limit time to delete tasks (default: 2592000)
46
+ -n, --name NAME Unique name of this node (default: PID.HOSTNAME)
47
+ -w, --delay SEC Delay time before running a task (default: 0)
48
+ -F, --from UNIX_TIME_OR_now Time to start scheduling
49
+ -i, --interval SEC Scheduling interval (default: 10)
50
+ -T, --type TYPE Lock database type (default: mysql)
51
+ -D, --database DB Database name
52
+ -H, --host HOST[:PORT] Database host
53
+ -u, --user NAME Database user name
54
+ -p, --password PASSWORD Database password
55
+ -d, --daemon PIDFILE Daemonize (default: foreground)
56
+ -f, --file PATH.yaml Read configuration file
57
+
58
+ Lock database (-T) is used to synchronize scheduling status over multiple servers. Rsched supports following database types:
59
+
60
+ * *mysql* uses MySQL as a lock database. Note that 'dbd-mysql' gem must be installed.
61
+ * *sqlite3* uses SQLite3 as a lock database. Note that 'dbd-sqlite3' gem must be installed.
62
+
63
+
64
+ One of --exec, --run or --configure is required. The behavior of the commands is described below:
65
+
66
+
67
+ === exec
68
+
69
+ Execute a command when an action is scheduled. ident, time and action is passed to the stdin with tab-separated format. The command have to exit with status code 0 when it succeeded.
70
+
71
+ _Example:_
72
+
73
+ #!/usr/bin/env ruby
74
+ ident, time, action = STDIN.read.split("\t", 3)
75
+ t = Time.at(time.to_i)
76
+ puts "scheduled on #{t} ident=#{ident} action=#{action}"
77
+
78
+ # $ rsched -a 'mysched * * * * * des' -T sqlite3 -D test.db -F now --exec ./this_file
79
+
80
+
81
+ === run
82
+
83
+ This is same as 'exec' except that this calls a method named 'run' defined in the file instead of executing the file. It is assumed it succeeded if the method doesn't any raise errors.
84
+
85
+ _Example:_
86
+
87
+ def run(ident, time, action)
88
+ t = Time.at(time)
89
+ puts "scheduled on #{t} ident=#{ident} action=#{action}"
90
+ end
91
+
92
+ # $ rsched -a 'mysched * * * * * des' -T sqlite3 -D test.db -F now --exec ./this_file.rb
93
+
data/bin/rsched ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # -*- coding: utf-8 -*-
3
+ require 'rubygems' unless defined?(gem)
4
+ here = File.dirname(__FILE__)
5
+ $LOAD_PATH << File.expand_path(File.join(here, '..', 'lib'))
6
+ require 'rsched/command/rsched'
@@ -0,0 +1,535 @@
1
+ require 'thread'
2
+ require 'time'
3
+
4
+ module RSched
5
+
6
+
7
+ class Lock
8
+ def initialize(hostname, timeout)
9
+ end
10
+
11
+ # acquired=token, locked=false, finished=nil
12
+ def aquire(ident, time)
13
+ end
14
+
15
+ def release(token)
16
+ end
17
+
18
+ def finish(token)
19
+ end
20
+
21
+ def delete_before(ident, time)
22
+ end
23
+ end
24
+
25
+
26
+ class DBLock < Lock
27
+ def initialize(hostname, timeout, uri, user, pass)
28
+ require 'dbi'
29
+ @hostname = hostname
30
+ @timeout = timeout
31
+ @db = DBI.connect(uri, user, pass)
32
+ init_db
33
+ end
34
+
35
+ def init_db
36
+ sql = ''
37
+ sql << 'CREATE TABLE IF NOT EXISTS rsched ('
38
+ sql << ' ident VARCHAR(256) NOT NULL,'
39
+ sql << ' time INT NOT NULL,'
40
+ sql << ' host VARCHAR(256),'
41
+ sql << ' timeout INT,'
42
+ sql << ' finish INT,'
43
+ sql << ' PRIMARY KEY (ident, time));'
44
+ @db.execute(sql)
45
+ end
46
+
47
+ def aquire(ident, time)
48
+ now = Time.now.to_i
49
+ if try_insert(ident, time, now) || try_update(ident, time, now)
50
+ return [ident, time]
51
+ elsif check_finished(ident, time)
52
+ return nil
53
+ else
54
+ return false
55
+ end
56
+ end
57
+
58
+ def release(token)
59
+ ident, time = *token
60
+ n = @db.do('UPDATE rsched SET timeout=? WHERE ident = ? AND time = ? AND host = ?;',
61
+ 0, ident, time, @hostname)
62
+ return n > 0
63
+ end
64
+
65
+ def finish(token)
66
+ ident, time = *token
67
+ now = Time.now.to_i
68
+ n = @db.do('UPDATE rsched SET finish=? WHERE ident = ? AND time = ? AND host = ?;',
69
+ now, ident, time, @hostname)
70
+ return n > 0
71
+ end
72
+
73
+ def delete_before(ident, time)
74
+ @db.do('DELETE FROM rsched WHERE ident = ? AND time < ? AND finish IS NOT NULL;', ident, time)
75
+ end
76
+
77
+ private
78
+ def try_insert(ident, time, now)
79
+ n = @db.do('INSERT INTO rsched (ident, time, host, timeout) VALUES (?, ?, ?, ?);',
80
+ ident, time, @hostname, now+@timeout)
81
+ return n > 0
82
+ rescue # TODO unique error
83
+ return false
84
+ end
85
+
86
+ def try_update(ident, time, now)
87
+ n = @db.do('UPDATE rsched SET host=?, timeout=? WHERE ident = ? AND time = ? AND finish IS NULL AND (timeout < ? OR host = ?);',
88
+ @hostname, now+@timeout, ident, time, now, @hostname)
89
+ return n > 0
90
+ end
91
+
92
+ def check_finished(ident, time)
93
+ x = @db.select_one('SELECT finish FROM rsched WHERE ident = ? AND time = ? AND finish IS NOT NULL;',
94
+ ident, time)
95
+ return x != nil
96
+ end
97
+ end
98
+
99
+
100
+ class Engine
101
+ class Sched
102
+ def initialize(cron, action, sched_start, from=Time.now.to_i, to=Time.now.to_i)
103
+ @tab = CronSpec::CronSpecification.new(cron)
104
+ @action = action
105
+ @sched_start = sched_start
106
+ @queue = []
107
+ @last_time = from
108
+ sched(to)
109
+ end
110
+
111
+ attr_reader :queue, :action
112
+
113
+ def sched(now)
114
+ while @last_time <= now
115
+ t = Time.at(@last_time).utc
116
+ if @tab.is_specification_in_effect?(t)
117
+ time = create_time_key(t)
118
+ @queue << time if time >= @sched_start
119
+ end
120
+ @last_time += 60
121
+ end
122
+ @queue.uniq!
123
+ end
124
+
125
+ private
126
+ if Time.respond_to?(:strptime)
127
+ def create_time_key(t)
128
+ Time.strptime(t.strftime('%Y%m%d%H%M00UTC'), '%Y%m%d%H%M%S%Z').to_i
129
+ end
130
+ else
131
+ require 'date'
132
+ def create_time_key(t)
133
+ Time.parse(DateTime.strptime(t.strftime('%Y%m%d%H%M00UTC'), '%Y%m%d%H%M%S').to_s).to_i
134
+ end
135
+ end
136
+ end
137
+
138
+ def initialize(lock, conf)
139
+ require 'cron-spec'
140
+ @lock = lock
141
+ @resume = conf[:resume]
142
+ @delay = conf[:delay]
143
+ @interval = conf[:interval]
144
+ @delete = conf[:delete]
145
+ @sched_start = conf[:from] || 0
146
+ @finished = false
147
+ @ss = {}
148
+
149
+ @mutex = Mutex.new
150
+ @cond = ConditionVariable.new
151
+ end
152
+
153
+ # {cron => (ident,action)}
154
+ def set_sched(ident, action, cron)
155
+ now = Time.now.to_i
156
+ @ss[ident] = Sched.new(cron, action, @sched_start, now-@resume, now-@delay)
157
+ end
158
+
159
+ def run(run_proc)
160
+ until @finished
161
+ one = false
162
+
163
+ now = Time.now.to_i - @delay
164
+ @ss.each_pair {|ident,s|
165
+
166
+ s.sched(now)
167
+ s.queue.delete_if {|time|
168
+ next if @finished
169
+
170
+ x = @lock.aquire(ident, time)
171
+ case x
172
+ when nil
173
+ # already finished
174
+ true
175
+
176
+ when false
177
+ # not finished but already locked
178
+ false
179
+
180
+ else
181
+ one = true
182
+ if process(ident, time, s.action, run_proc)
183
+ # success
184
+ @lock.finish(x)
185
+ try_delete(ident)
186
+ true
187
+ else
188
+ # fail
189
+ @lock.release(x)
190
+ false
191
+ end
192
+ end
193
+ }
194
+
195
+ break if @finished
196
+ }
197
+
198
+ return if @finished
199
+
200
+ unless one
201
+ cond_wait(@interval)
202
+ end
203
+
204
+ end
205
+ end
206
+
207
+ def shutdown
208
+ @finished = true
209
+ @mutex.synchronize {
210
+ @cond.broadcast
211
+ }
212
+ end
213
+
214
+ private
215
+ if ConditionVariable.new.method(:wait).arity == 1
216
+ require 'timeout'
217
+ def cond_wait(sec)
218
+ @mutex.synchronize {
219
+ Timeout.timeout(sec) {
220
+ @cond.wait(@mutex)
221
+ }
222
+ }
223
+ rescue Timeout::Error
224
+ end
225
+ else
226
+ def cond_wait(sec)
227
+ @mutex.synchronize {
228
+ @cond.wait(@mutex, sec)
229
+ }
230
+ end
231
+ end
232
+
233
+ def process(ident, time, action, run_proc)
234
+ begin
235
+ run_proc.call(ident, time, action)
236
+ return true
237
+ rescue
238
+ puts "failed ident=#{ident} time=#{time}: #{$!}"
239
+ $!.backtrace.each {|bt|
240
+ puts " #{bt}"
241
+ }
242
+ return false
243
+ end
244
+ end
245
+
246
+ def try_delete(ident)
247
+ @lock.delete_before(ident, Time.now.to_i-@delete)
248
+ end
249
+ end
250
+
251
+
252
+ class ExecRunner
253
+ def initialize(cmd)
254
+ @cmd = cmd + ' ' + ARGV.map {|a| Shellwords.escape(a) }.join(' ')
255
+ @iobuf = ''
256
+ end
257
+
258
+ def call(ident, time, action)
259
+ message = [ident, time, action].join("\t")
260
+ IO.popen(@cmd, "r+") {|io|
261
+ io.write(message) rescue nil
262
+ io.close_write
263
+ begin
264
+ while true
265
+ io.sysread(1024, @iobuf)
266
+ print @iobuf
267
+ end
268
+ rescue EOFError
269
+ end
270
+ }
271
+ if $?.to_i != 0
272
+ raise "Command failed"
273
+ end
274
+ end
275
+ end
276
+
277
+
278
+ end # module RSched
279
+
280
+
281
+ require 'optparse'
282
+
283
+ op = OptionParser.new
284
+
285
+ op.banner += " [-- <ARGV-for-exec-or-run>]"
286
+
287
+ confout = nil
288
+ schedule = []
289
+
290
+ defaults = {
291
+ :timeout => 600,
292
+ :resume => 3600,
293
+ :delete => 2592000,
294
+ :delay => 0,
295
+ :interval => 10,
296
+ :type => 'mysql',
297
+ :name => "#{Process.pid}.#{`hostname`.strip}",
298
+ }
299
+
300
+ conf = { }
301
+
302
+ op.on('--configure PATH.yaml', 'Write configuration file') {|s|
303
+ confout = s
304
+ }
305
+
306
+ op.on('--exec COMMAND', 'Execute command') {|s|
307
+ conf[:exec] = s
308
+ }
309
+
310
+ op.on('--run SCRIPT.rb', 'Run method named \'run\' defined in the script') {|s|
311
+ conf[:run] = s
312
+ }
313
+
314
+ op.on('-a', '--add EXPR', 'Add an execution schedule') {|s|
315
+ schedule << s
316
+ }
317
+
318
+ op.on('-t', '--timeout SEC', 'Retry timeout (default: 600)', Integer) {|i|
319
+ conf[:timeout] = i
320
+ }
321
+
322
+ op.on('-r', '--resume SEC', 'Limit time to resume tasks (default: 3600)', Integer) {|i|
323
+ conf[:resume] = i
324
+ }
325
+
326
+ op.on('-E', '--delete SEC', 'Limit time to delete tasks (default: 2592000)', Integer) {|i|
327
+ conf[:delete] = i
328
+ }
329
+
330
+ op.on('-n', '--name NAME', 'Unique name of this node (default: PID.HOSTNAME)') {|s|
331
+ conf[:name] = s
332
+ }
333
+
334
+ op.on('-w', '--delay SEC', 'Delay time before running a task (default: 0)', Integer) {|i|
335
+ conf[:delay] = i
336
+ }
337
+
338
+ op.on('-F', '--from YYYY-mm-dd_OR_now', 'Time to start scheduling') {|s|
339
+ if s == "now"
340
+ conf[:from] = Time.now.to_i
341
+ else
342
+ conf[:from] = Time.parse(s).to_i
343
+ end
344
+ }
345
+
346
+ #op.on('-x', '--kill-timeout SEC', 'Threashold time before killing process (default: timeout * 5)', Integer) {|i|
347
+ # conf[:kill_timeout] = i
348
+ #}
349
+
350
+ op.on('-i', '--interval SEC', 'Scheduling interval (default: 10)', Integer) {|i|
351
+ conf[:interval] = i
352
+ }
353
+
354
+ op.on('-T', '--type TYPE', 'Lock database type (default: mysql)') {|s|
355
+ conf[:type] = s
356
+ }
357
+
358
+ op.on('-D', '--database DB', 'Database name') {|s|
359
+ conf[:db_database] = s
360
+ }
361
+
362
+ op.on('-H', '--host HOST[:PORT]', 'Database host') {|s|
363
+ conf[:db_host] = s
364
+ }
365
+
366
+ op.on('-u', '--user NAME', 'Database user name') {|s|
367
+ conf[:db_user] = s
368
+ }
369
+
370
+ op.on('-p', '--password PASSWORD', 'Database password') {|s|
371
+ conf[:db_password] = s
372
+ }
373
+
374
+ op.on('-d', '--daemon PIDFILE', 'Daemonize (default: foreground)') {|s|
375
+ conf[:daemon] = s
376
+ }
377
+
378
+ op.on('-f', '--file PATH.yaml', 'Read configuration file') {|s|
379
+ conf[:file] = s
380
+ }
381
+
382
+
383
+ (class<<self;self;end).module_eval do
384
+ define_method(:usage) do |msg|
385
+ puts op.to_s
386
+ puts "error: #{msg}" if msg
387
+ exit 1
388
+ end
389
+ end
390
+
391
+
392
+ begin
393
+ if eqeq = ARGV.index('--')
394
+ argv = ARGV.slice!(0, eqeq)
395
+ ARGV.slice!(0)
396
+ else
397
+ argv = ARGV.slice!(0..-1)
398
+ end
399
+ op.parse!(argv)
400
+
401
+ if argv.length != 0
402
+ usage nil
403
+ end
404
+
405
+ if conf[:file]
406
+ require 'yaml'
407
+ yaml = YAML.load File.read(conf[:file])
408
+ y = {}
409
+ yaml.each_pair {|k,v| y[k.to_sym] = v }
410
+
411
+ conf = defaults.merge(y).merge(conf)
412
+
413
+ if conf[:schedule]
414
+ schedule = conf[:schedule] + schedule
415
+ end
416
+
417
+ if ARGV.empty? && conf[:args]
418
+ ARGV.clear
419
+ ARGV.concat conf[:args]
420
+ end
421
+
422
+ else
423
+ conf = defaults.merge(conf)
424
+ end
425
+
426
+ if conf[:run]
427
+ type = :run
428
+ elsif conf[:exec]
429
+ type = :exec
430
+ else
431
+ raise "--exec, --run or --configure is required"
432
+ end
433
+
434
+ if conf[:resume] <= conf[:timeout]
435
+ raise "resume time (-r) must be larger than timeout (-t)"
436
+ end
437
+
438
+ if conf[:delete] <= conf[:resume]
439
+ raise "delete time (-E) must be larger than resume time (-r)"
440
+ end
441
+
442
+ case conf[:type]
443
+ when 'mysql'
444
+ if !conf[:db_database] || !conf[:db_host] || !conf[:db_user]
445
+ raise "--database, --host and --user are required for mysql"
446
+ end
447
+ dbi = "DBI:Mysql:#{conf[:db_database]}:#{conf[:db_host]}"
448
+
449
+ when 'sqlite3'
450
+ if !conf[:db_database]
451
+ raise "--database is required for sqlite3"
452
+ end
453
+ dbi = "DBI:SQLite3:#{conf[:db_database]}"
454
+
455
+ else
456
+ raise "Unknown lock server type '#{conf[:type]}'"
457
+ end
458
+
459
+ rescue
460
+ usage $!.to_s
461
+ end
462
+
463
+
464
+ if confout
465
+ require 'yaml'
466
+
467
+ conf.delete(:file)
468
+ conf[:schedule] = schedule
469
+ conf[:args] = ARGV
470
+
471
+ y = {}
472
+ conf.each_pair {|k,v| y[k.to_s] = v }
473
+
474
+ File.open(confout, "w") {|f|
475
+ f.write y.to_yaml
476
+ }
477
+ exit 0
478
+ end
479
+
480
+
481
+ if schedule.empty?
482
+ usage "At least one -a is required"
483
+ end
484
+
485
+
486
+ puts "Using node name #{conf[:name]}"
487
+
488
+
489
+ if conf[:daemon]
490
+ exit!(0) if fork
491
+ Process.setsid
492
+ exit!(0) if fork
493
+ File.umask(0)
494
+ STDIN.reopen("/dev/null")
495
+ STDOUT.reopen("/dev/null", "w")
496
+ STDERR.reopen("/dev/null", "w")
497
+ File.open(conf[:daemon], "w") {|f|
498
+ f.write Process.pid.to_s
499
+ }
500
+ end
501
+
502
+
503
+ lock = RSched::DBLock.new(conf[:name], conf[:timeout], dbi, conf[:db_user].to_s, conf[:db_password].to_s)
504
+ worker = RSched::Engine.new(lock, conf)
505
+
506
+ schedule.each {|e|
507
+ tabs = e.split(/\s+/, 7)
508
+ ident = tabs.shift
509
+ action = tabs.pop
510
+ time = tabs.join(' ')
511
+ puts "Adding schedule ident='#{ident}' time='#{time}' action='#{action}'"
512
+ worker.set_sched(ident, action, time)
513
+ }
514
+
515
+
516
+ trap :INT do
517
+ puts "shutting down..."
518
+ worker.shutdown
519
+ end
520
+
521
+ trap :TERM do
522
+ puts "shutting down..."
523
+ worker.shutdown
524
+ end
525
+
526
+
527
+ if type == :run
528
+ load File.expand_path(conf[:run])
529
+ run_proc = method(:run)
530
+ else
531
+ run_proc = RSched::ExecRunner.new(conf[:exec])
532
+ end
533
+
534
+ worker.run(run_proc)
535
+
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rsched
3
+ version: !ruby/object:Gem::Version
4
+ hash: 27
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 0
10
+ version: 0.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Sadayuki Furuhashi
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-07-11 00:00:00 +09:00
19
+ default_executable: rsched
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: cron-spec
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - "="
28
+ - !ruby/object:Gem::Version
29
+ hash: 31
30
+ segments:
31
+ - 0
32
+ - 1
33
+ - 2
34
+ version: 0.1.2
35
+ type: :runtime
36
+ version_requirements: *id001
37
+ - !ruby/object:Gem::Dependency
38
+ name: dbi
39
+ prerelease: false
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ hash: 5
46
+ segments:
47
+ - 0
48
+ - 4
49
+ - 5
50
+ version: 0.4.5
51
+ type: :runtime
52
+ version_requirements: *id002
53
+ description:
54
+ email: frsyuki@gmail.com
55
+ executables:
56
+ - rsched
57
+ extensions: []
58
+
59
+ extra_rdoc_files:
60
+ - README.rdoc
61
+ files:
62
+ - bin/rsched
63
+ - lib/rsched/command/rsched.rb
64
+ - README.rdoc
65
+ has_rdoc: true
66
+ homepage:
67
+ licenses: []
68
+
69
+ post_install_message:
70
+ rdoc_options:
71
+ - --charset=UTF-8
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ none: false
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ hash: 3
80
+ segments:
81
+ - 0
82
+ version: "0"
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ none: false
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ hash: 3
89
+ segments:
90
+ - 0
91
+ version: "0"
92
+ requirements: []
93
+
94
+ rubyforge_project:
95
+ rubygems_version: 1.3.7
96
+ signing_key:
97
+ specification_version: 3
98
+ summary: Generic Reliable Scheduler
99
+ test_files: []
100
+