perfectsched 0.7.19 → 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.
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
+