rsched 0.1.0

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