perfectsched 0.7.19 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ .bundle
2
+ Gemfile.lock
3
+ vendor/*
4
+ coverage/*
5
+ pkg/*
6
+ spec/test.db
data/ChangeLog CHANGED
@@ -1,39 +1,3 @@
1
- == 2016-08-02 version 0.7.19
2
-
3
- * re-package
4
-
5
- == 2016-08-02 version 0.7.17
6
-
7
- * Upgraded perfectqueue v0.8.x
8
- * Upgraded sequel v3.48.0
9
- * Upgraded tzinfo v1.2.2
10
-
11
-
12
- == 2014-10-08 version 0.7.16
13
-
14
- * Upgraded perfectqueue v0.7.31
15
- * Upgraded sequel v3.48.0
16
-
17
-
18
- == 2014-06-10 version 0.7.15
19
-
20
- * RDBBackend: fixed modify_checked to update timeout and next_time
21
-
22
-
23
- == 2014-04-25 version 0.7.14
24
-
25
- * RDBBackend: fixed undefined local variable: 'url' to '@uri'
26
-
27
-
28
- == 2014-04-25 version 0.7.13
29
-
30
- * RDBBackend: fixed NoMethodError for @url
31
-
32
-
33
- == 2012-02-21 version 0.7.12
34
-
35
- * RDBBackend: added MySQL's SSL support
36
-
37
1
 
38
2
  == 2012-02-21 version 0.7.10
39
3
 
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source :rubygems
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,219 @@
1
+ # PerfectSched
2
+
3
+ PerfectSched is a highly available distributed cron built on top of RDBMS.
4
+
5
+ It provides at-least-once semantics; Even if a worker node fails during process a task, the task is retried by another worker.
6
+
7
+ PerfectSched also guarantees that only one worker server processes a task if the server is alive.
8
+
9
+ All you have to consider is implementing idempotent worker programs. It's recommended to use [PerfectQueue](https://github.com/treasure-data/perfectqueue) with PerfectSched.
10
+
11
+
12
+ ## API overview
13
+
14
+ ```
15
+ # open a schedule collection
16
+ PerfectSched.open(config, &block) #=> #<ScheduleCollection>
17
+
18
+ # add a schedule
19
+ ScheduleCollection#add(task_id, type, options)
20
+
21
+ # poll a scheduled task
22
+ # (you don't have to use this method directly. see following sections)
23
+ ScheduleCollection#poll #=> #<Task>
24
+
25
+ # get data associated with a task
26
+ Task#data #=> #<Hash>
27
+
28
+ # finish a task
29
+ Task#finish!
30
+
31
+ # retry a task
32
+ Task#retry!
33
+
34
+ # create a schedule reference
35
+ ScheduleCollection#[](key) #=> #<Schedule>
36
+
37
+ # chack the existance of the schedule
38
+ Schedule#exists?
39
+
40
+ # delete a schedule
41
+ Schedule#delete!
42
+ ```
43
+
44
+ ### Error classes
45
+
46
+ ```
47
+ ScheduleError < StandardError
48
+
49
+ ##
50
+ # Workers may get these errors:
51
+ #
52
+
53
+ AlreadyFinishedError < ScheduleError
54
+
55
+ NotFoundError < ScheduleError
56
+
57
+ PreemptedError < ScheduleError
58
+
59
+ ProcessStopError < RuntimeError
60
+
61
+ ##
62
+ # Client or other situation:
63
+ #
64
+
65
+ ConfigError < RuntimeError
66
+
67
+ AlreadyExistsError < ScheduleError
68
+
69
+ NotSupportedError < ScheduleError
70
+ ```
71
+
72
+
73
+ ### Example
74
+
75
+ ```ruby
76
+ # submit a task
77
+ PerfectSched.open(config) {|sc|
78
+ data = {'key'=>"value"}
79
+ options = {
80
+ :cron => '0 * * * *',
81
+ :delay => 30,
82
+ :timezone => 'Asia/Tokyo',
83
+ :next_time => Time.parse('2013-01-01 00:00:00 +0900').to_i,
84
+ :data => data,
85
+ }
86
+ sc.submit("sched-id", "type1", options)
87
+ }
88
+ ```
89
+
90
+
91
+ ## Writing a worker application
92
+
93
+ ### 1. Implement PerfectSched::Application::Base
94
+
95
+ ```ruby
96
+ class TestHandler < PerfectSched::Application::Base
97
+ # implement run method
98
+ def run
99
+ # do something ...
100
+ puts "acquired task: #{task.inspect}"
101
+
102
+ # call task.finish!, task.retry! or task.release!
103
+ task.finish!
104
+ end
105
+ end
106
+ ```
107
+
108
+ ### 2. Implement PerfectSched::Application::Dispatch
109
+
110
+ ```ruby
111
+ class Dispatch < PerfectSched::Application::Dispatch
112
+ # describe routing
113
+ route "type1" => TestHandler
114
+ route /^regexp-.*$/ => :TestHandler # String or Regexp => Class or Symbol
115
+ end
116
+ ```
117
+
118
+ ### 3. Run the worker
119
+
120
+ In a launcher script or rake file:
121
+
122
+ ```ruby
123
+ system('perfectsched run -I. -rapp/schedules/dispatch Dispatch')
124
+ ```
125
+
126
+ or:
127
+
128
+ ```ruby
129
+ require 'perfectsched'
130
+ require 'app/schedules/dispatch'
131
+
132
+ PerfectSched::Worker.run(Dispatch) {
133
+ # this method is called when the worker process is restarted
134
+ raw = File.read('config/perfectsched.yml')
135
+ yml = YAJL.load(raw)
136
+ yml[ENV['RAILS_ENV'] || 'development']
137
+ }
138
+ ```
139
+
140
+ ### Signal handlers
141
+
142
+ - **TERM**,**INT**,**QUIT:** shutdown
143
+ - **USR1**,**HUP:** restart
144
+ - **USR2:** reopen log files
145
+
146
+ ## Configuration
147
+
148
+ - **type:** backend type (required; see following sections)
149
+ - **log:** log file path (default: use stderr)
150
+ - **poll\_interval:** interval to poll tasks in seconds (default: 1.0 sec)
151
+ - **timezone:** default timezone (default: 'UTC')
152
+ - **alive\_time:** duration to continue a heartbeat request (default: 300 sec)
153
+ - **retry\_wait:** duration to retry a retried task (default: 300 sec)
154
+
155
+ ## Backend types
156
+
157
+ ### rdb\_compat
158
+
159
+ additional configuration:
160
+
161
+ - **url:** URL to the RDBMS (example: 'mysql://user:password@host:port/database')
162
+ - **table:** name of the table to use
163
+
164
+ ### rdb
165
+
166
+ Not implemented yet.
167
+
168
+
169
+ ## Command line management tool
170
+
171
+ ```
172
+ Usage: perfectsched [options] <command>
173
+
174
+ commands:
175
+ list Show list of registered schedules
176
+ add <key> <type> <cron> <data> Register a new schedule
177
+ delete <key> Delete a registered schedule
178
+ run <class> Run a worker process
179
+ init Initialize a backend database
180
+
181
+ options:
182
+ -e, --environment ENV Framework environment (default: development)
183
+ -c, --config PATH.yml Path to a configuration file (default: config/perfectsched.yml)
184
+
185
+ options for add:
186
+ -d, --delay SEC Delay time before running a schedule (default: 0)
187
+ -t, --timezone NAME Set timezone (default: UTC)
188
+ -s, --start UNIXTIME Set the first schedule time (default: now)
189
+ -a, --at UNIXTIME Set the first run time (default: start+delay)
190
+
191
+ options for run:
192
+ -I, --include PATH Add $LOAD_PATH directory
193
+ -r, --require PATH Require files before starting
194
+ ```
195
+
196
+ ### initializing a database
197
+
198
+ # assume that the config/perfectsched.yml exists
199
+ $ perfectsched init
200
+
201
+ ### submitting a task
202
+
203
+ $ perfectsched add s1 user_task '* * * * *' '{}'
204
+
205
+ ### listing tasks
206
+
207
+ $ perfectsched list
208
+ key type cron delay timezone next_time next_run_time data
209
+ s1 user_task * * * * * 0 UTC 2012-05-18 22:04:00 UTC 2012-05-18 22:04:00 UTC {}
210
+ 1 entries.
211
+
212
+ ### delete a schedule
213
+
214
+ $ perfectsched delete s1
215
+
216
+ ### running a worker
217
+
218
+ $ perfectsched run -I. -Ilib -rconfig/boot.rb -rapps/schedules/schedule_dispatch.rb ScheduleDispatch
219
+
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core'
5
+ require 'rspec/core/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec) do |t|
8
+ t.rspec_opts = ["-c", "-f progress", "-r ./spec/spec_helper.rb"]
9
+ t.pattern = 'spec/**/*_spec.rb'
10
+ t.verbose = true
11
+ end
12
+
13
+ task :coverage do |t|
14
+ ENV['SIMPLE_COV'] = '1'
15
+ Rake::Task["spec"].invoke
16
+ end
17
+
18
+ task :default => :build
19
+
@@ -0,0 +1,25 @@
1
+ #
2
+ # PerfectSched
3
+ #
4
+ # Copyright (C) 2012 FURUHASHI Sadayuki
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ module PerfectSched
20
+
21
+ require 'perfectqueue/application'
22
+ Application = PerfectQueue::Application
23
+
24
+ end
25
+
@@ -0,0 +1,254 @@
1
+ #
2
+ # PerfectSched
3
+ #
4
+ # Copyright (C) 2012 FURUHASHI Sadayuki
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ module PerfectSched
20
+ module Backend
21
+ class RDBCompatBackend
22
+ include BackendHelper
23
+
24
+ class Token < Struct.new(:row_id, :scheduled_time, :cron, :delay, :timezone)
25
+ end
26
+
27
+ def initialize(client, config)
28
+ super
29
+
30
+ require 'sequel'
31
+ url = config[:url]
32
+ unless url
33
+ raise ConfigError, "url option is required for the rdb_compat backend"
34
+ end
35
+
36
+ @table = config[:table]
37
+ unless @table
38
+ raise ConfigError, "table option is required for the rdb_compat backend"
39
+ end
40
+
41
+ #password = config[:password]
42
+ #user = config[:user]
43
+ @db = Sequel.connect(url, :max_connections=>1)
44
+ @mutex = Mutex.new
45
+
46
+ connect {
47
+ # connection test
48
+ }
49
+ end
50
+
51
+ MAX_SELECT_ROW = 4
52
+
53
+ attr_reader :db
54
+
55
+ def init_database(options)
56
+ sql = %[
57
+ CREATE TABLE IF NOT EXISTS `test_scheds` (
58
+ id VARCHAR(256) NOT NULL,
59
+ timeout INT NOT NULL,
60
+ next_time INT NOT NULL,
61
+ cron VARCHAR(128) NOT NULL,
62
+ delay INT NOT NULL,
63
+ data BLOB NOT NULL,
64
+ timezone VARCHAR(256) NULL,
65
+ PRIMARY KEY (id)
66
+ );]
67
+ connect {
68
+ @db.run sql
69
+ }
70
+ end
71
+
72
+ def get_schedule_metadata(key, options={})
73
+ connect {
74
+ row = @db.fetch("SELECT id, timeout, next_time, cron, delay, data, timezone FROM `#{@table}` LIMIT 1").first
75
+ unless row
76
+ raise NotFoundError, "schedule key=#{key} does not exist"
77
+ end
78
+ attributes = create_attributes(row)
79
+ return ScheduleMetadata.new(@client, key, attributes)
80
+ }
81
+ end
82
+
83
+ def list(options, &block)
84
+ connect {
85
+ @db.fetch("SELECT id, timeout, next_time, cron, delay, data, timezone FROM `#{@table}` ORDER BY timeout ASC") {|row|
86
+ attributes = create_attributes(row)
87
+ sched = ScheduleWithMetadata.new(@client, row[:id], attributes)
88
+ yield sched
89
+ }
90
+ }
91
+ end
92
+
93
+ def add(key, type, cron, delay, timezone, data, next_time, next_run_time, options)
94
+ data = data.dup
95
+ data[:type] = type
96
+ connect {
97
+ begin
98
+ n = @db["INSERT INTO `#{@table}` (id, timeout, next_time, cron, delay, data, timezone) VALUES (?, ?, ?, ?, ?, ?, ?);", key, next_run_time, next_time, cron, delay, data.to_json, timezone].insert
99
+ return Schedule.new(@client, key)
100
+ rescue Sequel::DatabaseError
101
+ raise AlreadyExistsError, "schedule key=#{key} already exists"
102
+ end
103
+ }
104
+ end
105
+
106
+ def delete(key, options)
107
+ connect {
108
+ n = @db["DELETE FROM `#{@table}` WHERE id=?;", key].delete
109
+ if n <= 0
110
+ raise NotFoundError, "schedule key=#{key} does no exist"
111
+ end
112
+ }
113
+ end
114
+
115
+ def modify(key, options)
116
+ ks = []
117
+ vs = []
118
+ [:cron, :delay, :timezone].each {|k|
119
+ # TODO type and data are not supported
120
+ if v = options[k]
121
+ ks << k
122
+ vs << v
123
+ end
124
+ }
125
+ return nil if ks.empty?
126
+
127
+ sql = "UPDATE `#{@table}` SET "
128
+ sql << ks.map {|k| "#{k}=?" }.join(', ')
129
+ sql << " WHERE id=?"
130
+
131
+ args = [sql].concat(vs)
132
+ args << key
133
+
134
+ connect {
135
+ n = @db[*args].update
136
+ if n <= 0
137
+ raise NotFoundError, "schedule key=#{key} does not exist"
138
+ end
139
+ }
140
+ end
141
+
142
+ def acquire(alive_time, max_acquire, options)
143
+ now = (options[:now] || Time.now).to_i
144
+ next_timeout = now + alive_time
145
+
146
+ connect {
147
+ while true
148
+ rows = 0
149
+ @db.fetch("SELECT id, timeout, next_time, cron, delay, data, timezone FROM `#{@table}` WHERE timeout <= ? ORDER BY timeout ASC LIMIT #{MAX_SELECT_ROW};", now) {|row|
150
+
151
+ n = @db["UPDATE `#{@table}` SET timeout=? WHERE id=? AND timeout=?;", next_timeout, row[:id], row[:timeout]].update
152
+ if n > 0
153
+ scheduled_time = Time.at(row[:next_time]).utc
154
+ attributes = create_attributes(row)
155
+ task_token = Token.new(row[:id], row[:next_time], attributes[:cron], attributes[:delay], attributes[:timezone])
156
+ task = Task.new(@client, row[:id], attributes, scheduled_time, task_token)
157
+ return [task]
158
+ end
159
+
160
+ rows += 1
161
+ }
162
+ if rows < MAX_SELECT_ROW
163
+ return nil
164
+ end
165
+ end
166
+ }
167
+ end
168
+
169
+ def heartbeat(task_token, alive_time, options)
170
+ now = (options[:now] || Time.now).to_i
171
+ row_id = task_token.row_id
172
+ scheduled_time = task_token.scheduled_time
173
+ next_run_time = now + alive_time
174
+
175
+ connect {
176
+ n = @db["UPDATE `#{@table}` SET timeout=? WHERE id=? AND next_time=?;", next_run_time, row_id, scheduled_time].update
177
+ if n < 0
178
+ raise AlreadyFinishedError, "task time=#{Time.at(scheduled_time).utc} is already finished"
179
+ end
180
+ }
181
+ end
182
+
183
+ def finish(task_token, options)
184
+ row_id = task_token.row_id
185
+ scheduled_time = task_token.scheduled_time
186
+ next_time = PerfectSched.next_time(task_token.cron, scheduled_time, task_token.timezone)
187
+ next_run_time = next_time + task_token.delay
188
+
189
+ connect {
190
+ n = @db["UPDATE `#{@table}` SET timeout=?, next_time=? WHERE id=? AND next_time=?;", next_run_time, next_time, row_id, scheduled_time].update
191
+ if n < 0
192
+ raise AlreadyFinishedError, "task time=#{Time.at(scheduled_time).utc} is already finished"
193
+ end
194
+ }
195
+ end
196
+
197
+ protected
198
+ def connect(&block)
199
+ @mutex.synchronize do
200
+ retry_count = 0
201
+ begin
202
+ block.call
203
+ rescue
204
+ # workaround for "Mysql2::Error: Deadlock found when trying to get lock; try restarting transaction" error
205
+ if $!.to_s.include?('try restarting transaction')
206
+ err = ([$!] + $!.backtrace.map {|bt| " #{bt}" }).join("\n")
207
+ retry_count += 1
208
+ if retry_count < MAX_RETRY
209
+ STDERR.puts err + "\n retrying."
210
+ sleep 0.5
211
+ retry
212
+ else
213
+ STDERR.puts err + "\n abort."
214
+ end
215
+ end
216
+ raise
217
+ ensure
218
+ @db.disconnect
219
+ end
220
+ end
221
+ end
222
+
223
+ def create_attributes(row)
224
+ timezone = row[:timezone] || 'UTC'
225
+ delay = row[:delay] || 0
226
+ cron = row[:cron]
227
+ next_time = Time.at(row[:next_time]).utc
228
+ next_run_time = Time.at(row[:timeout]).utc
229
+
230
+ begin
231
+ data = JSON.parse(row[:data] || '{}')
232
+ rescue
233
+ data = {}
234
+ end
235
+
236
+ type = data.delete('type') || ''
237
+
238
+ attributes = {
239
+ :timezone => timezone,
240
+ :delay => delay,
241
+ :cron => cron,
242
+ :data => data,
243
+ :next_time => next_time,
244
+ :next_run_time => next_run_time,
245
+ :type => type,
246
+ :message => nil, # not supported
247
+ :node => nil, # not supported
248
+ }
249
+ end
250
+
251
+ end
252
+ end
253
+ end
254
+